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
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.
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));
});
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.
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
});
}
});
Updated less than a minute ago