-
Notifications
You must be signed in to change notification settings - Fork 1.9k
Custom Buttons and Extensions
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)
- Adding custom functionality into medium-editor
- Overview
- Extensions
-
Buttons (
DefaultButton
object)
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.
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.)
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.
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
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
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.
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.
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
).
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.
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).
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.
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.
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.
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".
This method will be called on each extension whenever the toolbar is about to be hidden.
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
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.
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.
These options are all that are needed to differentiate all of the built-in buttons for medium-editor.
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.
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>•</b>',
contentFA: '<i class="fa fa-list-ul"></i>'
}
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>'
}
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>'
}
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'
}
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'
}
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).
If implementing a custom button that needs more functionality that the DefaultButton
provides as-is, these are the most helpful methods to override.
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.
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.
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.
For implementing an extension which accepts user input via the toolbar (similar to how the anchor button works), see Custom Extensions with Forms
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.
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()
}
});