Editor Plugin

Some editors display the contents of documents and allow the user to manipulate that content with a set of tools. Other editors, such as the terminal, don't interact with contents but allow the user to interact with other resources. This article is geared towards creating a custom editor.

🚧

Creating Editors

In most cases editors are created automatically for the panes they belong too. However it is possible to create editors and use them outside of panes. For instance an experimental and yet to be released plugin that creates a dashboard uses editors displayed in dashboard widgets instead of tabs.

Object structure

Besides tabs and panes there are several other objects involved in displaying content and making it editable. The following hierarchy describes the relationships between those objects.

  • Pane - Represent a single pane, housing multiple tabs
    • Editor - The editor responsible for displaying the document in the tab
    • Tab - A single tab (button) in a pane
      • Document - The representation of a file in the tab
        • Session - The session information of the editor
        • UndoManager - The object that manages the undo stack for this document

It is important to note that the Editor plugin(s) are related to the pane, not the tab. There's only one instance of a certain editor for each pane. The editor is responsible for displaying the content of a document based on the tab that is active. There can be a maximum of one tab active per pane.

Creating a Basic Editor

Register your Editor

Start by consuming the editors plugin and Editor base class which helps creating and registering your editor.

var Editor = imports.Editor;
var editors = imports.editors;

Then define the extensions that your editor can open. If there are none, leave the array empty.

var extensions = ["foo", "bar"];

Register your editor by giving it an id, name, factory reference and list of extensions. The handle which is returned should be passed to the register function.

var handle = editors.register("foo", "Foo", FooEditor, extensions);

register(null, {
    "my-editor": handle
});

Create a Factory for your Editor

The factory function mentioned above creates a new Editor plugin and this is where the meat of your implementation will be. The following code should all be straightforward.

function FooEditor(){
    var plugin = new Editor("Your Name", main.consumes, extensions);

    // Register the API
    plugin.freezePublicAPI({});
    
    // Initialize the Editor
    plugin.load(null, "my-plugin");
    
    return plugin;
}

The next sections will tell you how to extend this basic editor and make it function.

Draw the UI

Before anything else your plugin must draw it's user interface. The draw event is the designated place and time for this. This event is called the first time a tab for this editor becomes active in a pane. Usually this is immediately after the editor is created.

In this example the UI for the editor is loaded from an html file.

var container, contents;
plugin.on("draw", function(e) {
    container = e.htmlNode;
    
    // Insert the UI
    ui.insertHTML(container, require("text!./editor.html"), plugin);
    
    contents = container.querySelector(".contents");
});

🚧

Don't load CSS in the draw event

Since the draw event is called for each instance of the editor it is a bad idea to load css here. Instead load css in function that can only be called once and is defined outside the factory function of the editor.

Documents

Dealing with documents is the main responsibility of the editor. Documents are loaded, activated and unloaded as tabs in a pane are created, become active and are closed. Your editor is responsible for:

  • Rendering the contents of the document
  • Serializing the state of the document when requested
  • Setting the title of the document
  • Rendering the progress events of the document
  • Cleaning up after a document is unloaded

See also the guide on document sessions for more information on how sessions work

Loading

When a user opens a tab, for instance by double clicking on the file tree, a document is created and loaded into the appropriate editor. The documentLoad event is then called. Use this event to prepare the document's session to be displayed in the editor. It is only when the tab becomes active that the document will actually be displayed.

The following examples use a canvas element to render the contents of a document. Lets start by creating a canvas element in the documentLoad handler.

plugin.on("documentLoad", function(e){
    var doc = e.doc;
    var session = doc.getSession();

    session.canvas = document.createElement("canvas");    
});

Sessions are loaded and unloaded when the document moves between editors. In some cases you want to apply only some of the code in the documentLoad event handler to the session. See the session guide for more information.

👍

Add the next examples to the documentLoad handler

The following examples are to be added to the documentLoad event handler

Document Value

A custom plugin should process the contents of a document via the setValue event. Use this event to render the contents of the file (a string) in which ever way you want. In the example below we pass it to a function that will render the contents to the canvas element.

doc.on("setValue", function get(e) {
    renderOnCanvas(e.value, canvas);
}, session);

When the user uses your editor, it will most likely change the contents of the document. At some point the contents of the document will need to be serialized back into a string. For instance the save plugin will request state serialization when it wants to store the contents on disk. A custom plugin should implement a getValue handler to serialize the document's contents.

doc.on("getValue", function get(e) {
    return doc.changed
        ? serializeValue(session)
        : e.value;
}, session);

As an optimization, in the example above, the state is only serialized if the document actually changed. Otherwise the cached value is used.

Loading Progress

Your editor plugin could implement a progress indicator based on the progress event of the document. For more information see this article.

doc.on("progress", function(e){
    if (e.complete)
        hideProgress();
    else
        showProgress(e.loaded, e.total);
}, session);

Tab Title

Managing the title of the document is task your editor plugin must do. For instance the terminal plugin sets the title to the title from the tty. The ace plugin sets it to the filename of the file that's opened.

This code example shows you how to do that.

doc.tab.on("setPath", setTitle, session);

function setTitle(e) {
    var path = doc.tab.path;
    
    // Caption is the filename
    doc.title = basename(path);
    
    // Tooltip is the full path
    doc.tooltip = path;
}
setTitle();

Note that the basename() function comes from require("path").basename.

Cloud9 Theme

Setting the background color of a tab is the last thing an editor plugin must do. Often editor plugins want to change the color based on the theme. The following example shows how to accomplish this.

function setTheme(e) {
    var tab = doc.tab;
    var isDark = e.theme == "dark";
    
    tab.backgroundColor = BGCOLOR[e.theme];
    
    if (isDark) tab.classList.add("dark");
    else tab.classList.remove("dark");
}

layout.on("themeChange", setTheme, session);
setTheme({ theme: settings.get("user/general/@skin") });

In this example BGCOLOR is a hash of the default Cloud9 themes as keys and colors as values. We're working on a way to move styling for this to the themes. Stay tuned.

Document Unload

For some editors, such as the terminal, the unload of the session is best to occur in the unload event of the document. (For more information on unloading see this article). The following code example come straight from the terminal source code.

doc.on("unload", function(){
    // Stop the shell process at the remote machine
    session.kill();
    
    // Destroy the terminal
    if (session.terminal)
        session.terminal.destroy();
});

Activating

Whenever a tab becomes active, the editor for that document becomes visible and the documentActivate event is fired on the editor. An editor plugin must implement this event to hide any other document that is displayed and show the contents of the document that is now active.

Most plugins keep a reference to the active document in a local variable such as currentDocument or to the session as currentSession. This makes it easy to work with the currently active document.

This example swaps out the canvas element and displays the one of the active document.

plugin.on("documentActivate", function(e){
    currentDocument = e.doc;
    currentSession = e.doc.getSession();

    // Remove a previous canvas element, if any
    if (contents.firstChild)
        contents.removeChild(contents.firstChild); 

    // Set the canvas element to be displayed
    contents.appendChild(currentSession.canvas);
});

The active document of an editor can be retrieved like this.

var doc = editor.activeDocument;

Unloading

The unloading topic is extensively covered in the document guide. We refer to that section to learn about using the documentUnload event.

Clipboard

Your editor plugin can interact with clipboard actions using three events for copy, cut and paste.

Copy

The copy event is triggered when a user hits Cmd-C or Ctrl-C, uses the Edit/Copy menu item or the copy command is triggered programmatically.

The clipboardData object provides an API to set and retrieve data from the clipboard.

plugin.on("copy", function(e) {
    var data = getSerializedSelection();
    e.clipboardData.setData("text/plain", data);
});

Cut

The cut event is triggered when a user hits Cmd-X or Ctrl-X, uses the Edit/Cut menu item or the cut command is triggered programmatically.

plugin.on("cut", function(e) {
    var data = getSerializedSelection();
    removeSelection();
    e.clipboardData.setData("text/plain", data);
});

Paste

The paste event is triggered when a user hits Cmd-V or Ctrl-V, uses the Edit/Paste menu item or the paste command is triggered programmatically.

plugin.on("paste", function(e) {
    var data = e.clipboardData.getData("text/plain");
    if (data !== false)
        replaceSelection(data);
});

Serializing and Deserializing

When the document is serialized and deserialized the getState and setState events are called. This serialization step preserves the state of the editor between browser reloads.

In the following example, we'll store the scroll position of our editor in the state.

plugin.on("getState", function(e) {
    var session = e.doc.getSession();
    
    e.state.scrollTop = session.scrollTop;
    e.state.scrollLeft = session.scrollLeft;
});

When the state gets restored, we can restore the scroll position.

plugin.on("setState", function(e) {
    var session = e.doc.getSession();
    
    session.scrollTop = e.state.scrollTop;
    session.scrollLeft = e.state.scrollLeft;
    
    if (session == currentSession)
        session.update();
});

Note that we are reading and setting the state on the session object. Check out the following code that serves as an example on how you could manage the state of the session.

plugin.on("draw", function(e){
    // Set the scroll state on the session when it changes.
    contents.addEventListener("scroll", function(){
        currentSession.scrollLeft = this.scrollLeft;
        currentSession.scrollTop = this.scrollTop;
    });
});

plugin.on("documentLoad", function(e){
    var session = e.doc.getSession();
    
    // Define a way to update the scroll state for that session
    session.update = function(){
        contents.scrollTop = session.scrollTop;
        contents.scrollLeft = session.scrollLeft;
    }
});

plugin.on("documentActivate", function(e){
    currentSession = e.doc.getSession();
    
    // Call the update function when this session becomes active
    session.update();
});

Note that the update function could manage all the state changes, including appending the canvas to the contents DIV.

Focus & Blur

A plugin editor should indicate to the user when it is focussed and when it is blurred. The focus and blur events will be called at the appropriate time.

plugin.on("focus", function(){
    contents.className = "contents focus";
});
plugin.on("blur", function(){
    contents.className = "contents";
});

Resize

When the container that is assigned to the editor potentially changes in dimension the resize event is triggered. Hook this event to recalculate any positioning that is not done through css.

plugin.on("resize", function(){
    contents.style.width = (contents.parentNode.offsetWidth - 2) + "px";
});

Switching Editors

When tab.switchEditor() is called to switch the editor that has loaded the document (for instance from ace to hex, or from an imgeditor to hex) some editors want to validate the contents of the document before switching. For instance if your editor is a GUI drag&drop editor which stores the contents in JSON. When switching from ace back to your editor, you'll want to make sure the JSON is valid before allowing the switch.

The validate event allows your editor to implement validation logic.

plugin.on("validate", function(e){
    var doc = e.document;
    var info = e.info;
    
    try {
        var json = JSON.parse(doc.value);
    } catch(e) {
        info.title = "Invalid JSON";
        info.head = "Could not switch to GUI editor";
        info.message = "Invalid JSON was detected: " + e.message;
        return false;
    }
    
    return true;
});

The values set on the info object will be used to alert the user of the issue.

Full Example

define(function(require, exports, module) {
    main.consumes = ["Editor", "editors", "ui", "settings", "layout"];
    main.provides = ["my-editor"];
    return main;

    function main(options, imports, register) {
        var Editor = imports.Editor;
        var editors = imports.editors;
        var settings = imports.settings;
        var layout = imports.layout;
        var ui = imports.ui;
        
        var basename = require("path").basename;
        
        // Set extensions if any for your editor
        var extensions = ["foo", "bar"];
        
        // Register the editor
        var handle = editors.register("foo", "Foo", FooEditor, extensions);
        
        var BGCOLOR = { 
            "flat-light": "#F1F1F1", 
            "light": "#D3D3D3", 
            "light-gray": "#D3D3D3",
            "dark": "#3D3D3D",
            "dark-gray": "#3D3D3D" 
        };
                          
        function FooEditor(){
            var plugin = new Editor("Your Name", main.consumes, extensions);
            
            var container, contents;
            var currentSession, currentDocument;
            
            plugin.on("draw", function(e) {
                container = e.htmlNode;
                
                // Insert the UI
                ui.insertHTML(container, require("text!./editor.html"), plugin);
                
                contents = container.querySelector(".contents");
                
                // Set the scroll state on the session when it changes.
                contents.addEventListener("scroll", function(){
                    currentSession.scrollLeft = this.scrollLeft;
                    currentSession.scrollTop = this.scrollTop;
                });
            });
            
            function renderOnCanvas(){}
            function hideProgress(){}
            function showProgress(){}
            function serializeValue(){}
            function getSerializedSelection(){}
            function removeSelection(){}
            function replaceSelection(){}
            
            plugin.on("documentLoad", function(e){
                var doc = e.doc;
                var session = doc.getSession();
                
                doc.on("setValue", function get(e) {
                    renderOnCanvas(e.value, session.canvas);
                }, session);
                
                doc.on("getValue", function get(e) {
                    return doc.changed
                        ? serializeValue(session)
                        : e.value;
                }, session);
                
                doc.on("progress", function(e){
                    if (e.complete)
                        hideProgress();
                    else
                        showProgress(e.loaded, e.total);
                }, session);
                
                doc.tab.on("setPath", setTitle, session);
                
                function setTitle(e) {
                    var path = doc.tab.path;
                    
                    // Caption is the filename
                    doc.title = basename(path);
                    
                    // Tooltip is the full path
                    doc.tooltip = path;
                }
                setTitle();
                
                function setTheme(e) {
                    var tab = doc.tab;
                    var isDark = e.theme == "dark";
                    
                    tab.backgroundColor = BGCOLOR[e.theme];
                    
                    if (isDark) tab.classList.add("dark");
                    else tab.classList.remove("dark");
                }
                
                layout.on("themeChange", setTheme, session);
                setTheme({ theme: settings.get("user/general/@skin") });
                
                // Define a way to update the state for the session
                session.update = function(){
                    contents.scrollTop = session.scrollTop;
                    contents.scrollLeft = session.scrollLeft;
                    
                    // Set the canvas element to be displayed
                    contents.appendChild(currentSession.canvas);
                };
            
                session.canvas = document.createElement("canvas");    
            });
            plugin.on("documentActivate", function(e){
                currentDocument = e.doc;
                currentSession = e.doc.getSession();
            
                // Remove a previous canvas element, if any
                if (contents.firstChild)
                    contents.removeChild(contents.firstChild); 
            
                // Call the update function when this session becomes active
                currentSession.update();
            });
            plugin.on("documentUnload", function(e){
                var doc = e.doc;
                var session = doc.getSession();
                
                if (session.canvas.parentNode)
                    session.canvas.parentNode.removeChild(session.canvas);
                delete session.canvas;
            });
            plugin.on("cut", function(e) {
                var data = getSerializedSelection();
                removeSelection();
                e.clipboardData.setData("text/plain", data);
            });
            plugin.on("paste", function(e) {
                var data = e.clipboardData.getData("text/plain");
                if (data !== false)
                    replaceSelection(data);
            });
            plugin.on("getState", function(e) {
                var session = e.doc.getSession();
                
                e.state.scrollTop = session.scrollTop;
                e.state.scrollLeft = session.scrollLeft;
            });
            plugin.on("setState", function(e) {
                var session = e.doc.getSession();
                
                session.scrollTop = e.state.scrollTop;
                session.scrollLeft = e.state.scrollLeft;
                
                if (session == currentSession)
                    session.update();
            });
            plugin.on("focus", function(){
                contents.className = "contents focus";
            });
            plugin.on("blur", function(){
                contents.className = "contents";
            });
            plugin.on("resize", function(){
                contents.style.width = (contents.parentNode.offsetWidth - 2) + "px";
            });

            plugin.freezePublicAPI({
                
            });
            
            plugin.load(null, "my-editor");
            
            return plugin;
        }
        
        register(null, {
            "my-editor": handle
        });
    }
});