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
- Document - The representation of a file in the tab
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
});
}
});
Updated less than a minute ago