Previewer Plugin

The preview pane is an editor that hosts different types of Previewers that are responsible for rendering a certain type of content. The screenshot below shows the preview pane with the markdown previewer. Many previewers, such as the markdown previewer offer live previewing; they update the rendered content as the user makes changes in the source file. This article is aimed at writing a custom previewer.

Cloud9 comes with three default previewers:

  • Raw - displaying the file as is
  • Browser - display html/css files as well as any other file that a browser can display
  • Markdown - display a rendered version of a markdown file
1428

Creating a Basic Previewer

A basic previewer that is used by default for html files and http urls.

var plugin = new Previewer("Your name", main.consumes, {
    caption: "Name of Previewer",
    index: 10,
    selector: function(path) {
        return /(?:\.html|\.htm|\.xhtml)$|^https?\:\/\//.test(path);
    }
});

This adds a menu item to the dropdown that contains a list of available previewers, which a user can manually select. Your previewer is automatically selected when the selector function returns truthy for a path of a file that is previewed.

1360

Settings Menu

The settings menu, which shows when clicking on the settings button in the preview toolbar, can be configured by your plugin using the context menu api. The preview.settingsMenu property gives a reference to the Menu object.

plugin.on("load", function(){
    preview.settingsMenu.append(new MenuItem({ 
        caption: "Scroll Preview Element Into View", 
        command: "scrollPreviewElementIntoView"
    }, plugin));
});
312

Preview Sessions

Your previewer plugin is a singleton that takes preview sessions and adds customized functionality to them, managing their lifetime from start to end and handles activation and deactivation of sessions as they are brought into view and out of view (via the context menu or tab switching).

A preview session represents a preview of a single file. Use the session object to store data and objects related to that session.

577

In the diagram above there are two panes and 6 tabs. Each of the four tabs with either a green or a red line is a preview to a different file (a session). The active tab (in white) shows the preview editor and the "Draw Area" is the area where your plugin can draw the rendered content of the file - more on this below. In this example there is one inactive tab that holds a markdown preview session. Both the active tabs and an additional inactive tab each hold a session from your previewer plugin.

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

Start

When a user opens a preview tab, for instance by using the preview button in the toolbar, a session is created and loaded into the appropriate previewer. The sessionStart event is then fired. Use this event to prepare the preview session to display rendered content in the draw area of the preview editor. It is only when the tab becomes active that the session will actually be displayed.

The following examples use an iframe element to render the preview of the contents of a file. This is by far the most common way of doing this. Lets start by creating an iframe element in the sessionStart handler.

plugin.on("sessionStart", function(e) {
    var session = e.session;
    var editor = e.editor;
    
    var iframe = document.createElement("iframe");
    
    iframe.style.width = "100%";
    iframe.style.height = "100%";
    iframe.style.border = 0;
    
    // Store objects on session
    session.iframe = iframe;
    session.editor = editor;
    
    editor.container.appendChild(session.iframe);
});

Loading already loaded session

Sessions are ended and started when a preview tab moves between panes. That is why it is highly recommended to only redo the part of the initialization that is specific to the preview editor.

plugin.on("sessionStart", function(e) {
    var session = e.session;
    var editor = e.editor;
    
    if (session.iframe) {
        session.editor = editor;
        editor.container.appendChild(session.iframe);
        return;
    }

Messaging between Iframe and previewer

We recommend using the html5 postMessage() api to communicate between the iframe and your plugin. This api allows you to communicate even when the content of your iframe is from a different origin (port, protocol or (sub-)domain).

Add something like this to your sessionStart event handler to listen to commands from your iframe. The previewOrigin variable should be set to the origin string of whatever you load in your iframe.

session.id = "plugin-name" + counter++;

var onMessage = function(e) {
    if (c9.hosted && e.origin !== previewOrigin) // This is here for security reasons
        return;
    
    if (e.data.id != session.id) // Only listen to messages for this session
        return;
    
    if (e.data.message == "stream.document") {
        // Send document here
    }
};
window.addEventListener("message", onMessage, false);

Previewers can be opened for files that have a tab already open and for files that only exist on the file system. Loading the document's content and then sending it to the iframe can be done with the following code:

session.source = e.source;

if (session.previewTab) {
    var doc = session.previewTab.document;
    
    session.source.postMessage({
        type: "document",
        content: doc.value
    }, "*");
    
    if (!doc.hasValue())
        doc.once("setValue", function(){
            emit("update", { previewDocument: doc });
        });
}
else {
    fs.readFile(session.path, function(err, data) {
        if (err)
            return showError(err.message);
        
        session.source.postMessage({
            type: "document",
            content: data
        }, "*");
    });
}

In the iframe you can send and receive messages like this. Note that in this example the host and id variables are send via the url.

var host = location.href.match(/host=(.*?)(\&|$)/)[1];
var id = location.href.match(/id=(.*)/)[1];
var parent = window.opener || window.parent;

window.addEventListener("message", function(e){
    if (host != "local" && e.origin !== host) // This is here fore security reasons
        return;
    
    if (e.data.type == "document") {
        // Example
        preview.innerHTML = renderContents(e.data.content);
    }
}, false);

function send(message){
    message.id = id;
    parent.postMessage(message, host == "local" ? "*" : host);
}

send({ message: "stream.document" });

Destroying Session

When a session is destroyed you want to clean up any references to objects and any events you might have set. Because the sessionEnd event is fired throughout the life time of a session you cannot use that to clean up these elements - your preview would lose state each time a tab is moved to a new pane.

Instead add a destroy() method to the session.

session.destroy = function(){
    delete session.editor;
    delete session.iframe;
    
    window.removeEventListener("message", onMessage, false);
}

This is called when the tab that hosts the session is closed and all the popout windows are closed.

End

The sessionEnd event is fired when the tab that hosts the session is closed, when a user selects a different previewer and when the tab is moved to another pane. Use this event to clean up editor specific state.

plugin.on("sessionEnd", function(e) {
    var iframe = e.session.iframe;
    
    // Remove the iframe from the draw area
    iframe.parentNode.removeChild(iframe);
    
    // Remove any residual loading state
    e.tab.classList.remove("loading");
});

Activate

Whenever a preview tab becomes active, the session for that tab becomes active and the sessionActivate event is fired on your previewer plugin. A previewer plugin must implement this event to show the contents of the session that is now active.

plugin.on("sessionActivate", function(e) {
    var session = e.session;
    
    session.iframe.style.display = "block";
    session.editor.setLocation(session.path);
    session.editor.setButtonStyle("Markdown", "page_white.png");
});

Deactivate

Whenever a preview tab becomes inactive, the session for that tab becomes inactive and the sessionDeactivate event is fired on your previewer plugin. A previewer plugin must implement this event to hide the contents of the session that is no longer active.

plugin.on("sessionDeactivate", function(e) {
    var session = e.session;
    session.iframe.style.display = "none";
});

Navigation

When a preview is opened for the first time or any time when a user changes the path in the location bar of the preview editor, the navigate event is fired.

plugin.on("navigate", function(e) {
    var tab = e.tab;
    var url = e.url;
    var session = e.session;
    var editor = session.editor;
    var iframe = session.iframe;
    
    // Set the location of the iframe that will display the content
    iframe.src = buildURL(url, session.id);
    
    // Set the state of the preview tab
    tab.title = 
    tab.tooltip = "[B] " + url;
    tab.classList.add("loading");
    
    // Set the location of the location bar
    editor.setLocation(url);
});

The buildURL() method used above can be anything. Make sure to add the host and id parameters used to set up communication between the iframe contents and this plugin if required.

Updating

When a user changes a file that is being previewed, for instance by typing in an editor or by a file watcher that fires, the update event is fired.

The example below assumes the iframe messaging is set up and the full document contents is sent to that iframe when the update event is fired. For most common document sizes this is efficient to do at every key stroke.

plugin.on("update", function(e) {
    var session = e.session;
    if (!session.source) return; // Renderer is not loaded yet

    session.source.postMessage({
        type: "document",
        content: e.previewDocument.value
    }, "*");
});

Reloading

When the user hits the reload button or uses the reloadpreview command the reload event is fired to indicate that your plugin should refresh the rendered state.

In the example below the iframe reloads it's current location.

plugin.on("reload", function(e){
    var iframe = e.session.iframe;
    iframe.src = iframe.src;
});

Popping Out into a new Window

When a user clicks the pop out button in the preview toolbar the popout event is fired on your plugin. The user will expect a new window (or tab) to open with the preview content shown. This is especially useful when the user wants to use the built-in browser tools or share the preview with another person.

In this example the iframe source is cleaned up and used to open a new window.

plugin.on("popout", function(e){
    var src = getIframeSrc(e.session.iframe);
    window.open(src);
});

Serializing and Deserializing

Similar to how editors serialize and deserialize state, use the getState and setState events to get and set any additional state you'd like to store between reloads of the IDE.

plugin.on("getState", function(e) {
    var session = e.session;
    var state = e.state;
    
    state.myProperty = session.myProperty;
});

plugin.on("setState", function(e) {
    var session = e.session;
    var state = e.state;
    
    session.myProperty = state.myProperty;
});

Full Example

The example below is the actual implementation of the markdown previewer.

define(function(require, exports, module) {
    main.consumes = [
        "c9", "Previewer", "fs", "dialog.error", "commands", "tabManager", 
        "layout", "settings"
    ];
    main.provides = ["preview.markdown"];
    return main;

    function main(options, imports, register) {
        var Previewer = imports.Previewer;
        var c9 = imports.c9;
        var fs = imports.fs;
        var commands = imports.commands;
        var layout = imports.layout;
        var settings = imports.settings;
        var tabManager = imports.tabManager;
        var showError = imports["dialog.error"].show;
        var dirname = require("path").dirname;
        
        /***** Initialization *****/
        
        var plugin = new Previewer("Ajax.org", main.consumes, {
            caption: "Markdown",
            index: 200,
            selector: function(path) {
                return path && path.match(/(?:\.md|\.markdown)$/i);
            }
        });
        var emit = plugin.getEmitter();
        
        var BGCOLOR = { 
            "flat-light": "rgb(250,250,250)", 
            "light": "rgba(255, 255, 255, 0.88)", 
            "light-gray": "rgba(255, 255, 255, 0.88)",
            "dark": "rgba(255, 255, 255, 0.88)",
            "dark-gray": "rgba(255, 255, 255, 0.88)" 
        };
        
        var counter = 0;
        var HTMLURL, previewOrigin;
        
        /***** Methods *****/
        
        function getPreviewUrl(fn) {
            if (options.local && document.baseURI.substr(0, 5) == "file:")
                return setTimeout(getPreviewUrl.bind(null, fn), 100);
            else if (HTMLURL)
                return fn(HTMLURL);
            
            HTMLURL = (options.staticPrefix 
                ? (options.local ? dirname(document.baseURI) + "/static" : options.staticPrefix) 
                    + options.htmlPath
                : "/static/plugins/c9.ide.preview/previewers/markdown.html")
                    + "?host=" + (options.local ? "local" : location.origin);
                
            if (HTMLURL.charAt(0) == "/")
                HTMLURL = location.protocol + "//" + location.host + HTMLURL;
    
            previewOrigin = HTMLURL.match(/^(?:[^\/]|\/\/)*/)[0];
            
            fn(HTMLURL);
        }
        
        /***** Lifecycle *****/
        
        plugin.on("load", function(){
            
        });
        plugin.on("sessionStart", function(e) {
            var doc = e.doc;
            var session = e.session;
            var tab = e.tab;
            var editor = e.editor;
            
            if (session.iframe) {
                session.editor = editor;
                editor.container.appendChild(session.iframe);
                return;
            }
            
            var iframe = document.createElement("iframe");
            
            iframe.setAttribute("nwfaketop", true);
            iframe.setAttribute("nwdisable", true);

            iframe.style.width = "100%";
            iframe.style.height = "100%";
            iframe.style.border = 0;
            
            function setTheme(e) {
                iframe.style.backgroundColor = BGCOLOR[e.theme];
            }
            layout.on("themeChange", setTheme, doc);
            setTheme({ theme: settings.get("user/general/@skin") });
            
            if (options.local) {
                iframe.addEventListener("load", function(){
                    // @todo to do this correctly stack needs to allow switching previewer
                    // plugin.activeSession.add(iframe.contentWindow.location.href);
                    
                    iframe.contentWindow.opener = window;
                    if (iframe.contentWindow.start)
                        iframe.contentWindow.start(window);
                });
            }
            
            var onMessage = function(e) {
                if (c9.hosted && e.origin !== previewOrigin)
                    return;
                
                if (e.data.id != session.id)
                    return;
                
                if (e.data.message == "exec") {
                    commands.exec(e.data.command);
                }
                else if (e.data.message == "focus") {
                    tabManager.focusTab(tab);
                }
                else if (e.data.message == "stream.document") {
                    session.source = e.source;
                    
                    if (session.previewTab) {
                        var doc = session.previewTab.document;
                        
                        session.source.postMessage({
                            type: "document",
                            content: doc.value
                        }, "*");
                        
                        if (!doc.hasValue())
                            doc.once("setValue", function(){
                                emit("update", { previewDocument: doc });
                            });
                    }
                    else {
                        fs.readFile(session.path, function(err, data) {
                            if (err)
                                return showError(err.message);
                            
                            session.source.postMessage({
                                type: "document",
                                content: data
                            }, "*");
                        });
                    }
                    
                    session.source.postMessage({
                        type: "keys",
                        keys: commands.getExceptionBindings()
                    }, "*");
                    
                    tab.classList.remove("loading");
                }
            };
            window.addEventListener("message", onMessage, false);
            
            // Set iframe
            session.iframe = iframe;
            session.id = "markdown" + counter++;
            
            session.destroy = function(){
                delete session.editor;
                delete session.iframe;
                
                window.removeEventListener("message", onMessage, false);
            }
            
            // Load the markup renderer
            getPreviewUrl(function(url) { 
                iframe.src = url + "&id=" + session.id; 
            });
            
            session.editor = editor;
            editor.container.appendChild(session.iframe);
        });
        plugin.on("sessionEnd", function(e) {
            var tab = e.tab;
            var session = e.session;
            var iframe = session.iframe;
            
            iframe.parentNode.removeChild(iframe);
            
            tab.classList.remove("loading");
        });
        plugin.on("sessionActivate", function(e) {
            var session = e.session;
            
            session.iframe.style.display = "block";
            session.editor.setLocation(session.path);
            session.editor.setButtonStyle("Markdown", "page_white.png");
        });
        plugin.on("sessionDeactivate", function(e) {
            var session = e.session;
            session.iframe.style.display = "none";
        });
        plugin.on("navigate", function(e) {
            var tab = e.tab;
            var session = e.session;
            var iframe = session.iframe;
            var editor = session.editor;
            
            tab.classList.add("loading");
            
            tab.title = 
            tab.tooltip = "[M] " + e.url;
            editor.setLocation(e.url);
            
            iframe.src = iframe.src;
        });
        plugin.on("update", function(e) {
            var session = e.session;
            if (!session.source) return; // Renderer is not loaded yet
    
            session.source.postMessage({
                type: "document",
                content: e.previewDocument.value
            }, "*");
        });
        plugin.on("reload", function(e){
            plugin.activeDocument.tab.classList.add("loading");
            
            var iframe = e.session.iframe;
            iframe.src = iframe.src;
        });
        plugin.on("popout", function(e){
            var src = e.session.iframe.src;
            window.open(src);
        });
        plugin.on("unload", function(){
        });
        
        /***** Register and define API *****/
        
        /**
         * Previewer for markdown content.
         **/
        plugin.freezePublicAPI({
        });
        
        register(null, {
            "preview.markdown": plugin
        });
    }
});