Skip to content

Custom Buttons and Extensions

Nate Mielnik edited this page Jun 27, 2015 · 54 revisions

Extensions (v5.0.0)

What is an Extension?

Extensions are custom actions or commands that can be passed in via the extensions option to medium-editor. They can replace existing buttons if they share the same name, or can add additional custom functionality into the editor. Extensions can be implemented in any way, and just provide a way to hook into medium-editor. New extensions can be created by extending the exposed MediumEditor.Extension object via MediumEditor.Extension.extend().

Examples of functionality that are implemented via built-in extensions:

Examples of custom built external extensions:

What is a Button?

Buttons are a specific type of Extension which have a contract with the MediumEditor toolbar. Buttons have specific lifecycle methods that MediumEditor and the toolbar use to interact with these specific types of Extensions. These contract create easy hooks, allowing custom buttons to:

  • Display an element in the toolbar (ie a clickable button/link)
  • Execute an action on the editor text when clicked (ie bold, underline, blockquote, etc.)
  • Update the appearance of the element based on the user selection (ie the bold button looks 'active' if the selected text is already bold, 'inactive' if the text is not bold)

All of the built-in MediumEditor buttons are just Button Extensions with different configuration:

  • bold, italic, underline, strikethrough
  • subscript, superscript
  • image
  • quote, pre
  • orderedlist, unorderedlist
  • indent, outdent
  • justifyLeft, justifyCenter, justifyRight, justifyFull
  • h1, h2, h3, h4, h5, h6
  • removeFormat

Examples of custom built external buttons:

What is a Form Extension?

Form Extensions are a specific type of Button Extension which collect input from the user via the toolbar. Form Extensions extend from Button, and thus inherit all of the lifecycle methods of a Button. In addition, Form Extensions have some additional methods exposed to interact with MediumEditor and provide some common functionality.

Built-in Form Extensions

  • Anchor Button
    • The 'anchor' Button is actually a form extension, which when clicked, prompts the the user for a url (as well as some optional checkboxes) via a control in the toolbar and converts the selected text into a link. If the selection is already a link, clicking the button unwraps text within the anchor tag.
  • FontSize Button (beta)
    • The 'fontsize' Button is a form extension, which when clicked, allows the user to modify the size of the existing text via a control in the toolbar.

Extensions

Example: DisableContextMenuExtension

You can find a demo of this example in the source code via extension-example.html.

To interact with the demo, load the page from your fork in a browser via file://[Medium Editor Source Root]/demo/extension-example.html

Define the Extension

As a simple example, let's create an extension that disables the context menu from appearing when the user right-clicks on the editor.

Defining this extension is as simple as calling MediumEditor.Extension.extend() and passing in the methods/properties we want to override.

var DisableContextMenuExtension = MediumEditor.Extension.extend({
  name: 'disable-context-menu'
});

We now have an extension named 'disable-context-menu' which we can pass into MediumEditor like this:

new MediumEditor('.editable', {
  extensions: {
    'disable-context-menu': new DisableContextMenuExtension()
  }
});

Attaching To Context Menu Event

To make the extension actually do something, we'll want to attach to the contextmenu event on all elements of the editor. We can set this up by implementing the init() method, which is called on every Extension during setup of MediumEditor:

var DisableContextMenuExtension = MediumEditor.Extension.extend({
  name: 'disable-context-menu',

  init: function () {
    this.getEditorElements().forEach(function (element) {
      this.base.on(element, 'contextmenu', this.handleContextmenu.bind(this));
    }, this);
  },

  handleContextmenu: function (event) { }
});

Here, we're leveraging some of the helpers that are available to all Extensions.

  • We're using this.getEditorElements(), which is a helper function to give us an array containing all elements maintained by this editor.
  • We're using this.base, which is a reference to the MediumEditor instance.
  • We're using this.base.on(), which is a method of MediumEditor for attaching to DOM Events. Using this method ensures our event handlers will be detached when MediumEditor is destroyed.

NOTE

  • There are a few helper methods that allow us to make calls directly into the MediumEditor instance without having to reference this.base. One of them is a reference to the on() method, so instead of the above code we can just use this.on(element, 'contextmenu', this.handleContextmenu.bind(this)) which is what we'll use in the rest of the example.

Adding Functionality

So, the last piece we need is to handle the contextmenu event and prevent the default action:

var DisableContextMenuExtension = MediumEditor.Extension.extend({
  name: 'disable-context-menu',

  init: function () {
    this.getEditorElements().forEach(function (element) {
      this.base.on(element, 'contextmenu', this.handleContextmenu.bind(this));
    }, this);
  },

  handleContextmenu: function (event) {
    event.preventDefault();
  }
});

Now we have a working extension which prevents the context menu from showing up for any of the elements. Let's add some more functionality to allow for toggling this feature on and off.

Leveraging Custom Event Listeners

Let's say we wanted to support toggling on/off the disable-context-menu extension, for a specific element, whenever the user presses ESCAPE. To do this, we'll need to add 2 pieces of functionality:

  1. Listen to the keydown event on each element. For this, we can leverage the built-in editableKeyDown custom event. This allows us to use the 2nd argument of custom event listeners (the active editor element) to toggle on/off a data-allow-context-menu attribute on the element.

  2. When the contextmenu event fires, we only want to prevent the context menu from appearing if the data-allow-context-menu attribute is not present.

var DisableContextMenuExtension = MediumEditor.Extension.extend({
  name: 'disable-context-menu',

  init: function () {
    this.getEditorElements().forEach(function (element) {
      this.on(element, 'contextmenu', this.handleContextmenu.bind(this));
    }, this);
    this.subscribe('editableKeydown', this.handleKeydown.bind(this));
  },

  handleContextmenu: function (event) {
    if (!event.currentTarget.getAttribute('data-allow-context-menu')) {
      event.preventDefault();
    }
  },

  handleKeydown: function (event, editable) {
    // If the user hits escape, toggle the data-allow-context-menu attribute
    if (MediumEditor.util.isKey(event, MediumEditor.util.keyCode.ESCAPE)) {
      if (editable.hasAttribute('data-allow-context-menu')) {
        editable.removeAttribute('data-allow-context-menu');
      } else {
        editable.setAttribute('data-allow-context-menu', true);
      }
    }
  }
});

NOTE

For events like keydown, we could always use currentTarget and not need to use the reference to the editable element (like how we use the currentTarget when handling the contextmenu event). However, there may be times when we want to trigger one of these events manually, and this allows us to specify exactly which editable element we want to trigger the event for. It's also a handy standardization for events which are more complicated, like the custom focus and blur events.

Extension Interface

The following are properties and method that MediumEditor will attempt to use / call to interact with the extension internally.

name (string)

The name to identify the extension by. This is used for calls to MediumEditor.getExtensionByName(name) to retrieve the extension. If not defined, this will be set to whatever identifier was used when passing the extension into MediumEditor via the extensions option.

var MyExtension = MediumEditor.Extension.extend({
  name: 'myextension'
});

var myExt = new MyExtension();

var editor = new MediumEditor('.editor', {
  extensions: {
    'myextension': myExt
  }
});

editor.getExtensionByName(`myextension`) === myExt //true

init()

Called by MediumEditor during initialization. The .base property will already have been set to current instance of MediumEditor when this is called. All helper methods will exist as well.

See the code above for an example of implementing the init() method.


checkState(node)

If implemented, this method will be called one or more times after the state of the editor & toolbar are updated. When the state is updated, the editor does the following:

  1. Find the parent node containing the current selection
  2. Call checkState(node) on each extension, passing the node as an argument
  3. Get the parent node of the previous node
  4. Repeat steps #2 and #3 until we move outside the parent contenteditable

Arguments

  1. node (Node):
  • Current node, within the ancestors of the selection, that is being checked whenever a selection change occurred.

Here's an example of an extension that will add/remove a class to editor elements depending on whether or the current selection is within an element with a custom data attribute:

var EditedExtension = MediumEditor.Extension.extend({
  name: 'edited',
  checkState: function (node) {
    // checkState is called multiple times for each selection change
    // so only store a value if the attribute was found
    if (!this.foundAttribute && node.getAttribute('data-edited')) {
      this.foundAttribute = true;
    }

    // Once we've moved up the ancestors to the container element
    // we know we're done iterating up and can add/remove the css class
    if (MediumEditor.util.isMediumEditorElement(node)) {
      if (this.foundAttribute) {
        node.classList.add('edited-text');
      } else {
        node.classList.remove('edited-text');
      }
      // Make sure the property is not persisted for the next time
      // selection is updated
      delete this.foundAttribute;
    }
  }
});

var editedExt = new EditedExtension();

var editor = new MediumEditor('.editor', {
  extensions: {
    'edited': editedExt
  }
});

destroy()

If implemented, this method will be called whenever the MediumEditor is being destroyed (via a call to `MediumEditor.destroy()).

This gives the extensions the chance to remove any created html, custom event handlers or execute any other cleanup tasks that should be performed.


queryCommandState()

If implemented, this method will be called once on each extension when the state of the editor/toolbar is being updated.

If this method returns a non-null value, the extension will be ignored as the code climbs the dom tree.

If this method returns true, and the setActive() method is defined on the extension, the setActive() method will be called by MediumEditor.

Returns: boolean OR null


isActive()

If implemented, this method will be called when MediumEditor has determined that this extension is 'active' for the current selection.

This may be called when the editor & toolbar are being updated, but only if the queryCommandState() or isAlreadyApplied() methods are implemented, and when called, return true.

Returns: boolean


isAlreadyApplied(node)

If implemented, this method is similar to checkState() in that it will be called repeatedly as MediumEditor moves up the DOM to update the editor & toolbar after a state change.

NOTE:

  • This method will NOT be called if checkState() has been implemented.
  • This method will NOT be called if queryCommandState() is implemented and returns a non-null value when called.

Arguments

  1. node (Node):
  • Node to check for whether the current extension has already been applied.

Returns: boolean


setActive()

If implemented, this method is called when MediumEditor knows that this extension is currently enabled.

Currently, this method is called when updating the editor & toolbar, and if queryCommandState() or isAlreadyApplied(node) return true when called.


setInactive()

If implemented, this method is called when MediumEditor knows that this extension has not been applied to the current selection. Curently, this is called at the beginning of each state change for the editor & toolbar.

After calling this, MediumEditor will attempt to update the extension, either via checkState() or the combination of queryCommandState(), isAlreadyApplied(node), isActive(), and setActive()

Extension Helpers

The following are helpers that are either set by MediumEditor during initialization, or are helper methods which either route calls to the MediumEditor instance or provide common functionality for all extensions.

base (MediumEditor)

A reference to the instance of MediumEditor that this extension is part of.

For example, if you wanted to save the current selection within MediumEditor to be used later, you could call the following within your extension:

this.base.saveSelection();

window (Window)

A reference to the content window to be used by this instance of MediumEditor. This maps to the value of the contentWindow option that is passed into MediumEditor.

For example, if you wanted to get the width of the window that contains this instance of MediumEditor, you could call the following within your extension:

var windowWidth = this.window.innerWidth;

document (Document)

A reference to the owner document to be used by this instance of MediumEditor. This maps to the value of the ownerDocument option that is passed into MediumEditor.

For example, to create an element in the current document corresponding to this instance of MediumEditor, you would call the following within your extension:

var button = this.document.createElement('button');

getEditorElements()

Returns a reference to the array of elements monitored by this instance of MediumEditor.

Returns: Array of HTMLElements

For example, the following is the destroy method of the Placeholder Extension, which removes an attribute from all editor elements:

Placeholder = Extension.extend({
  // ...
  destroy: function () {
    this.getEditorElements().forEach(function (el) {
      if (el.getAttribute('data-placeholder') === this.text) {
        el.removeAttribute('data-placeholder');
      }
    }, this);
  },
  // ...
});

getEditorId()

Returns the unique identifier for this instance of MediumEditor

Returns: Number

For example, the following is an excerpt from the createToolbar() method of the Toolbar extension, which creates the toolbar element and gives it a unique id tied to the editor's unique id:

Placeholder = Extension.extend({
  // ...
  createToolbar: function () {
    var toolbar = this.document.createElement('div');

    toolbar.id = 'medium-editor-toolbar-' + this.getEditorId();
    toolbar.className = 'medium-editor-toolbar';

    // ...

    return toolbar;
  },
  // ...
});

getEditorOption(option)

Returns the value of a specific option used to initialize the MediumEditor object.

Arguments

  1. option ('String')
  • Name of the MediumEditor option to retrieve.

Returns: Value of the MediumEditor option

For example, the following is an excerpt from the getTemplate() method of the Anchor extension, which checks the buttonLabels option MediumEditor to decide the appearance of the 'save' button in the form:

AnchorForm = FormExtension.extend({
  // ...
  getTemplate: function () {
    var template = [
      '<input type="text" class="medium-editor-toolbar-input" placeholder="', this.placeholderText, '">'
    ];

    template.push(
      '<a href="#" class="medium-editor-toolbar-save">',
      this.getEditorOption('buttonLabels') === 'fontawesome' ? '<i class="fa fa-check"></i>' : this.formSaveLabel,
      '</a>'
    );

    // ...
  },
  // ...
});

Extension Proxy Methods

  • These are methods that are just proxied calls into existing MediumEditor functions:

execAction(action, opts)

Calls MediumEditor.execAction(action, opts)

For example, the Button Extension will - by default - call execAction() each time a button is clicked, to trigger a command:

Button = Extension.extend({
  // ...
  handleClick: function (event) {
    event.preventDefault();
    event.stopPropagation();

    var action = this.getAction(); // 'bold', 'italic', etc.

    if (action) {
      this.execAction(action);
    }
  },
  // ...
});

on(target, event, listener, useCapture)

Calls MediumEditor.on(target, event, listener, useCapture)

This allows extensions to easily attach event handlers to the DOM which will automatically be detached when MediumEditor is destroyed.

For example, when the Anchor Preview Extension detects a mouseover event for a link, it will attach to the mouseout event for the same link so it can hide the anchor preview:

AnchorPreview = Extension.extend({
  // ...
  handleEditableMouseover: function (event) {
    // ...
    this.instanceHandleAnchorMouseout = this.handleAnchorMouseout.bind(this);
    this.on(this.anchorToPreview, 'mouseout', this.instanceHandleAnchorMouseout);
    // ...
  },
  // ...
});

off(target, event, listener, useCapture)

Calls MediumEditor.off(target, event, listener, useCapture)

To compliment the above example for on(target, event, listener, useCapture), when the Anchor Preview Extension detects a mouseout event for a link, it will detach the the event handler for mouseout until the next time the mouse hovers over the link:

AnchorPreview = Extension.extend({
  // ...
  handleAnchorMouseout: function () {
    this.anchorToPreview = null;
    this.off(this.activeAnchor, 'mouseout', this.instanceHandleAnchorMouseout);
    this.instanceHandleAnchorMouseout = null;
  },
  // ...
});

subscribe(name, listener)

Calls MediumEditor.subscribe(name, listener)

For example, the Keyboard Commands Extension will subscribe to the editableKeydown custom event during init(), to monitor when keys are pressed while any of the editor elements are focused:

KeyboardCommands = Extension.extend({
  // ...
  init: function () {
    Extension.prototype.init.apply(this, arguments);

    this.subscribe('editableKeydown', this.handleKeydown.bind(this));
    // ...
  },
  // ...
});

trigger(name, data, editable)

Calls MediumEditor.trigger(name, data, editable)

For example, the Toolbar Extension triggers the hideToolbar custom event whenever the toolbar is being hidden:

Toolbar = Extension.extend({
  // ...
  hideToolbar: function () {
    if (this.isDisplayed()) {
      this.getToolbarElement().classList.remove('medium-editor-toolbar-active');
      this.trigger('hideToolbar', {}, this.base.getFocusedElement());
    }
  },
  // ...
});

Buttons

Example: Highlight Button

A simple example that uses rangy and the CSS Class Applier Module to support highlighting of text:

rangy.init();

var Highlighter = MediumEditor.extensions.button.extend({
  name: 'highlighter',

  init: function () {
    Extension.prototype.init.apply(this, arguments);

    this.classApplier = rangy.createCssClassApplier("highlight", {
        elementTagName: 'mark',
        normalize: true
    });

    this.button = this.document.createElement('button');
    this.button.classList.add('medium-editor-action');
    this.button.innerHTML = '<b>H</b>';
    this.button.title = 'Highlight';
    this.on(this.button, 'click', this.handleClick.bind(this));
  },

  handleClick: function (event) {
    this.classApplier.toggleSelection();
  },

  checkState: function (node) {
    if(node.tagName == 'MARK') {
        this.button.classList.add('medium-editor-button-active');
    }
  }
});

var editor = new MediumEditor('.editor', {
  extensions: {
    'highlighter': new Highlighter()
  }
});

Button Extension API

Form Buttons

Example: Anchor

From Extension API

Clone this wiki locally