Skip to content

Custom Buttons and Extensions

MartyIX edited this page Jun 17, 2015 · 54 revisions

Adding custom functionality into medium-editor

At its core, medium-editor wraps the native document.execCommand method to execute built-in browser rich text actions (ie bold, italic, underline, etc.) Currently, each of these supported actions are represented by a button that can be displayed in the toolbar (via the buttons option passed as an array of strings)

Overview

What is an Extension?

Extensions are custom actions or commands that can be passed in via 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. Extensions may or may not have a button in the toolbar.

What is a Button?

Buttons are a specific type of extension which medium-editor uses to implement all of the built-in buttons that can be displayed in the toolbar. The DefaultButton object implements the core methods of extensions, and also implements common logic shared between all of the medium-editor buttons (ie button clicks, executing actions, showing as inactive/active in the toolbar etc.)

Extensions

Both extensions and buttons should be passed into medium-editor at creation time via the extensions option:

var MyExtension = function () {}

var editor = new MediumEditor('.editor', {
    buttons: ['bold', 'italic', 'underline', 'myextension'],
    extensions: { 'myextension': new MyExtension() }
});

This passed an instance of MyExtension into medium-editor. By putting 'myextension' in the list of buttons, medium-editor will attempt to retrieve a button from the extension to place in the toolbar.

Extension Properties

.parent (boolean) & .base (MediumEditor)

The .base property of an extension can automatically be set to a reference of the current instance of medium-editor. This will happen when the medium-editor instance in instantiated, as long as the .parent property of the extension is set to true.

var MyExtension = function () {
    this.parent = true;
}

var myEx = new MyExtension();

var editor = new MediumEditor('.editor', {
    buttons: ['bold', 'italic', 'underline', 'myextension'],
    extensions: { 'myextension': myEx }
});

myEx.base === editor // true

.name (string)

The name of the extension. If not set manually, this will automatically be set to the key passed into the extensions option object (in the previous example, the name would be myextension).

This name can be used to retrieve the extension out of medium-editor via the getExtensionByName() method.

var MyExtension = function () {}

var myEx = new MyExtension();

var editor = new MediumEditor('.editor', {
    buttons: ['bold', 'italic', 'underline', 'myextension'],
    extensions: { 'myextension': myEx }
});

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

Methods

.init(instance)

If defined, MediumEditor will call this method while it initializes its list of buttons and extensions. This will give the extension an opportunity to initialize itself before any other methods are called on the extension.

Arguments:

instance (MediumEditor): A reference to the current instance of MediumEditor. If using the .parent property, .base will already be set to the MediumEditor instance, so this argument may not be needed.


.deactivate()

If defined, MediumEditor will call this method on each extension whenever its cleaning itself up (when the .deactivate() method is called on MediumEditor). The purpose of this function should be to cleanup any DOM Elements, event handlers, etc. that shouldn't be left behind whenever MediumEditor is shut-down / removed.


.getButton(instance)

If defined, MediumEditor will call this method when it's building the toolbar. This method should return either a DOM Node or a string of HTML to be appended to the <li> element created for each toolbar button.

MediumEditor will not setup any events for this button, so it's the job of the extension to ensure and events are setup before it returns the button (ie click).


.isActive()

This should return true or false reflecting whether the current extension is "active", and should reflect the state that the extension thinks it is in. The extension should not try to look at the current selection to determine whether it's active or not, that's taken care of via the .isAlreadyApplied() or checkState() methods.


.setInactive()

This method will be called when MediumEditor needs to reset all of the extensions before it attempts to inspect the current selection and determine which extensions should be marked as "active".

Implementations of this function should do whatever they need to update the state of the extension so it believes it is inactive (for buttons, this usually means remove any "active" classes from the button element).


.isAlreadyApplied(node) vs .checkState(node)

Whenever MediumEditor attempts to update the toolbar to reflect the current state of the user's selection, there are two approaches used to allow the extensions to determine whether they are active or not, as well as update themselves to reflect it.

Arguments:

node: A DOM Node within the current selection, or an ancestor of the DOM Node within the current selection.

.isAlreadyApplied(node)

This is the current approach for checking whether the current extension applies to the specified node passed as an argument. MediumEditor will look at the deepest level DOM Node within the current selection, and then traverse up that node and its ancestors until it hits the contenteditable container element that MediumEditor is using.

As it traverses up the DOM, .isAlreadyApplied(node) will be called each time with the current node being passed as the parameter. This function should inspect the node and determine whether the extension has already been applied to this node.

If .isAlreadyApplied() returns true for any node, MediumEditor will immediately call .setActive() on the extension, and no longer call .isAlreadyApplied() on this extension for any other node as it traverses up the tree.

If .isAlreadyApplied() returns a false value, MediumEditor will continue calling this method for each node as it traverses up the tree.

.checkState(node)

This is the older approach for checking whether the current extension applies to the specified Node and is being supported for backwards compatability. MediumEditor will look at the deepest level DOM Node within the current selection, and then traverse up that node and its ancestors until it hits the contenteditable container element that MediumEditor is using.

As it traverses up the DOM, .checkState(node) will be called for each node being touched. It is the job of the button to take any action based on this method (ie activate/deactivate a button in the toolbar) as MediumEditor will simply call this method every time the user's selection changes.

MediumEditor will not look at any return value for this function, and there is no way to prevent this method from being called on every node as MediumEditor traverses up the DOM tree.


.setActive()

This method corresponds to extension which use the .isAlreadyApplied() method. For extension which are implementing the checkState() method, this will never be called.

If MediumEditor has determined that the current extension is "active" based on the current selection (generally when isAlreadyApplied() returns true) this method will be called so the extension can update itself as "active".

For buttons, this generally means adding some css classes to the button element to make the button appear "active".


.onHide()

This method will be called on each extension whenever the toolbar is about to be hidden.

Buttons (DefaultButton object)

Since all the built-in buttons used in the MediumEditor toolbar share a lot of common functionality, there is a DefaultButton object which is an extension with reusable core functionality.

To help with making custom extensions, this object definition is exposed in a way that it can be extended upon for cases where you want to replace an existing button, or create a new button, but don't need to write a completely custom extension.

The object definition can be accessed via MediumEditor.statics.DefaultButton

Details

Internally, all buttons used by MediumEditor are either instances of DefaultButton or derive from DefaultButton. Each built-in button is data-driven using a set of options to define the appearance and behavior of each button.

DefaultButton(options, instance)

Arguments:

options (object): Object containing options for what this button will look like and what kind of action will take place when it's clicked.

instance (MediumEditor): A reference to the current instance of MediumEditor.

This is the source code for the DefaultButton constructor;

DefaultButton = function (options, instance) {
    this.options = options;
    this.name = options.name;
    this.init(instance);
};

Since it's an extension, MediumEditor will still call the .init() method when it's being initialized and pass it the MediumEditor instance via the instance param, so it's not necessary to call this.init() in the constructor.

In the event that you don't have the instance of MediumEditor, or if you need to do some additional tasks when your custom button is created, this constructor is simple enough to override. Just ensure this.options and this.name are set appropriately.

Options

These options are all that are needed to differentiate all of the built-in buttons for medium-editor.

name (String)

Name of the button. This corresponds to the array of strings passed into the medium-editor via the buttons option. For each name passed via this option, MediumEditor will look first look for a custom extension, then look for a built-in button that has this name.


action (String or Function)

This is the action that should take place when the button is clicked. By default, this means the first argument passed to MediumEditor.execAction(). Generally, this will directly result in a call to document.execCommand() with the action being passed as the first argument (ie bold, italic, justifyLeft)

The action will also be set as the value of the data-action attribute set on the button element itself.

When useQueryState is true, the action will be used when calling the browser's native document.queryCommandState() to determine whether the button should be displayed as "active" in the toolbar

Example: unorderedlist

{
    name: 'unorderedlist',
    action: 'insertunorderedlist', // action used for document.execCommand() and document.queryCommandState()
    aria: 'unordered list',
    tagNames: ['ul'],
    useQueryState: true,
    contentDefault: '<b>&bull;</b>',
    contentFA: '<i class="fa fa-list-ul"></i>'
}

aria (string)

The value to set as the aria-label attribute of the button element.

Example: orderedlist

{
    name: 'orderedlist',
    action: 'insertorderedlist',
    aria: 'ordered list', // the orderedlist button with have an aria-label of 'ordered list'
    tagNames: ['ol'],
    useQueryState: true,
    contentDefault: '<b>1.</b>',
    contentFA: '<i class="fa fa-list-ol"></i>'
}

tagNames (array)

An array of element tag names (lowercase) which would indicate the button action is already applied to the element (ie <b> or <strong> tags would represent that an element is bold).

Example: subscript

{
    name: 'subscript',
    action: 'subscript',
    aria: 'subscript',
    tagNames: ['sub'],  // browser's will wrap an element in a <sub> tag for the 'subscript' command
    contentDefault: '<b>x<sub>1</sub></b>',
    contentFA: '<i class="fa fa-subscript"></i>'
}

style (object)

A name-value pair to use to compare to a call to window.getComputedStyle() to determine whether a button should be "active" or not for the current selection. The value of this object can be | (pipe) delimited list of valid values.

NOTE: This can be problematic because of the various ways different browsers can compute the style value for an element. This also will not work as expected if the CSS property is not inherited by default (ie text-decoration can't be used to determine whether something is underlined or strikethrough, since it's not inherited by default)

Example: bold NOTE: Since useQueryState is true, the style will only be used as a fallback if document.queryCommandState() fails (which it often does in firefox)

{
    name: 'bold',
    action: 'bold',
    aria: 'bold',
    tagNames: ['b', 'strong'],
    style: {
        prop: 'font-weight',
        value: '700|bold' // If font-weight is 700 or bold, this button will be activated
    },
    useQueryState: true,
    contentDefault: '<b>B</b>',
    contentFA: '<i class="fa fa-bold"></i>',
    key: 'b'
}

useQueryState (boolean)

When set to true, MediumEditor will attempt to use document.queryCommandState() to determine whether this button should be "active" or not.

NOTE: If this is set to true, only if document.queryCommandState() fails (throws) will it attempt to traverse up the DOM and use style or tagNames to determine whether this button is "active"

Example: italic

{
    name: 'italic',
    action: 'italic',
    aria: 'italic',
    tagNames: ['i', 'em'],
    style: {
        prop: 'font-style',
        value: 'italic'
    },
    useQueryState: true, // use document.queryCommandState('italic') to determine whether this button is "active'
    contentDefault: '<b><i>I</i></b>',
    contentFA: '<i class="fa fa-italic"></i>',
    key: 'i'
}

contentDefault (string) and contentFA (string)

These are used as the content of button itself when displayed in the toolbar.

By default, the contentDefault value will be used as the innerHTML of the button.

When the buttonLabels option is set to 'fontawesome' in MediumEditor, the contentFA value will be used as the innerHTML of the button (if defined).

Custom Buttons

If implementing a custom button that needs more functionality that the DefaultButton provides as-is, these are the most helpful methods to override.

.handleClick(event)

By default, the DefaultButton will attach to the click event of the button via this method. Overriding this will allow your button to execute its custom functionality.


.createButton()

Override this method to manually create your own custom button the way you would like it to appear.

This method should create a new DOM element and return it. If you do not want a click handler automatically attached to this button, you'll need to override the .init() method of DefaultButton as well.


.isAlreadyApplied(node)

Override this method to control when your button will be marked as "active" in the toolbar. See the description for the isAlreadyApplied(node) method for extensions defined earlier on in the documentation.

Custom Extensions with Forms

For implementing an extension which accepts user input via the toolbar (similar to how the anchor button works), see Custom Extensions with Forms

Examples

Anchor Extension

See the implementation of AnchorExtension in the medium-editor repo for an example of a complex extension which extends DefaultButton. This is the source code for how the built-in anchor button works for medium-editor.

Highligher Extension (using checkState)

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

rangy.init();

function Highlighter() {
    this.button = document.createElement('button');
    this.button.className = 'medium-editor-action';
    this.button.textContent = 'H';
    this.button.onclick = this.onClick.bind(this);
    this.classApplier = rangy.createCssClassApplier("highlight", {
        elementTagName: 'mark',
        normalize: true
    });
}
Highlighter.prototype.onClick = function() {
    this.classApplier.toggleSelection();
};
Highlighter.prototype.getButton = function() {
    return this.button;
};
Highlighter.prototype.checkState = function (node) {
    if(node.tagName == 'MARK') {
        this.button.classList.add('medium-editor-button-active');
    }
};

var e = new MediumEditor('.editor', {
    buttons: ['highlight', 'bold', 'italic', 'underline'],
    extensions: {
        'highlight': new Highlighter()
    }
});
Clone this wiki locally