medium-editor
Version:
Medium.com WYSIWYG editor clone.
376 lines (303 loc) • 14 kB
JavaScript
(function () {
'use strict';
var AnchorForm = MediumEditor.extensions.form.extend({
/* Anchor Form Options */
/* customClassOption: [string] (previously options.anchorButton + options.anchorButtonClass)
* Custom class name the user can optionally have added to their created links (ie 'button').
* If passed as a non-empty string, a checkbox will be displayed allowing the user to choose
* whether to have the class added to the created link or not.
*/
customClassOption: null,
/* customClassOptionText: [string]
* text to be shown in the checkbox when the __customClassOption__ is being used.
*/
customClassOptionText: 'Button',
/* linkValidation: [boolean] (previously options.checkLinkFormat)
* enables/disables check for common URL protocols on anchor links.
*/
linkValidation: false,
/* placeholderText: [string] (previously options.anchorInputPlaceholder)
* text to be shown as placeholder of the anchor input.
*/
placeholderText: 'Paste or type a link',
/* targetCheckbox: [boolean] (previously options.anchorTarget)
* enables/disables displaying a "Open in new window" checkbox, which when checked
* changes the `target` attribute of the created link.
*/
targetCheckbox: false,
/* targetCheckboxText: [string] (previously options.anchorInputCheckboxLabel)
* text to be shown in the checkbox enabled via the __targetCheckbox__ option.
*/
targetCheckboxText: 'Open in new window',
// Options for the Button base class
name: 'anchor',
action: 'createLink',
aria: 'link',
tagNames: ['a'],
contentDefault: '<b>#</b>',
contentFA: '<i class="fa fa-link"></i>',
init: function () {
MediumEditor.extensions.form.prototype.init.apply(this, arguments);
this.subscribe('editableKeydown', this.handleKeydown.bind(this));
},
// Called when the button the toolbar is clicked
// Overrides ButtonExtension.handleClick
handleClick: function (event) {
event.preventDefault();
event.stopPropagation();
var range = MediumEditor.selection.getSelectionRange(this.document);
if (range.startContainer.nodeName.toLowerCase() === 'a' ||
range.endContainer.nodeName.toLowerCase() === 'a' ||
MediumEditor.util.getClosestTag(MediumEditor.selection.getSelectedParentElement(range), 'a')) {
return this.execAction('unlink');
}
if (!this.isDisplayed()) {
this.showForm();
}
return false;
},
// Called when user hits the defined shortcut (CTRL / COMMAND + K)
handleKeydown: function (event) {
if (MediumEditor.util.isKey(event, MediumEditor.util.keyCode.K) && MediumEditor.util.isMetaCtrlKey(event) && !event.shiftKey) {
this.handleClick(event);
}
},
// Called by medium-editor to append form to the toolbar
getForm: function () {
if (!this.form) {
this.form = this.createForm();
}
return this.form;
},
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>'
);
template.push('<a href="#" class="medium-editor-toolbar-close">',
this.getEditorOption('buttonLabels') === 'fontawesome' ? '<i class="fa fa-times"></i>' : this.formCloseLabel,
'</a>');
// both of these options are slightly moot with the ability to
// override the various form buildup/serialize functions.
if (this.targetCheckbox) {
// fixme: ideally, this targetCheckboxText would be a formLabel too,
// figure out how to deprecate? also consider `fa-` icon default implcations.
template.push(
'<div class="medium-editor-toolbar-form-row">',
'<input type="checkbox" class="medium-editor-toolbar-anchor-target" id="medium-editor-toolbar-anchor-target-field-' + this.getEditorId() + '">',
'<label for="medium-editor-toolbar-anchor-target-field-' + this.getEditorId() + '">',
this.targetCheckboxText,
'</label>',
'</div>'
);
}
if (this.customClassOption) {
// fixme: expose this `Button` text as a formLabel property, too
// and provide similar access to a `fa-` icon default.
template.push(
'<div class="medium-editor-toolbar-form-row">',
'<input type="checkbox" class="medium-editor-toolbar-anchor-button">',
'<label>',
this.customClassOptionText,
'</label>',
'</div>'
);
}
return template.join('');
},
// Used by medium-editor when the default toolbar is to be displayed
isDisplayed: function () {
return MediumEditor.extensions.form.prototype.isDisplayed.apply(this);
},
hideForm: function () {
MediumEditor.extensions.form.prototype.hideForm.apply(this);
this.getInput().value = '';
},
showForm: function (opts) {
var input = this.getInput(),
targetCheckbox = this.getAnchorTargetCheckbox(),
buttonCheckbox = this.getAnchorButtonCheckbox();
opts = opts || { value: '' };
// TODO: This is for backwards compatability
// We don't need to support the 'string' argument in 6.0.0
if (typeof opts === 'string') {
opts = {
value: opts
};
}
this.base.saveSelection();
this.hideToolbarDefaultActions();
MediumEditor.extensions.form.prototype.showForm.apply(this);
this.setToolbarPosition();
input.value = opts.value;
input.focus();
// If we have a target checkbox, we want it to be checked/unchecked
// based on whether the existing link has target=_blank
if (targetCheckbox) {
targetCheckbox.checked = opts.target === '_blank';
}
// If we have a custom class checkbox, we want it to be checked/unchecked
// based on whether an existing link already has the class
if (buttonCheckbox) {
var classList = opts.buttonClass ? opts.buttonClass.split(' ') : [];
buttonCheckbox.checked = (classList.indexOf(this.customClassOption) !== -1);
}
},
// Called by core when tearing down medium-editor (destroy)
destroy: function () {
if (!this.form) {
return false;
}
if (this.form.parentNode) {
this.form.parentNode.removeChild(this.form);
}
delete this.form;
},
// core methods
getFormOpts: function () {
// no notion of private functions? wanted `_getFormOpts`
var targetCheckbox = this.getAnchorTargetCheckbox(),
buttonCheckbox = this.getAnchorButtonCheckbox(),
opts = {
value: this.getInput().value.trim()
};
if (this.linkValidation) {
opts.value = this.checkLinkFormat(opts.value);
}
opts.target = '_self';
if (targetCheckbox && targetCheckbox.checked) {
opts.target = '_blank';
}
if (buttonCheckbox && buttonCheckbox.checked) {
opts.buttonClass = this.customClassOption;
}
return opts;
},
doFormSave: function () {
var opts = this.getFormOpts();
this.completeFormSave(opts);
},
completeFormSave: function (opts) {
this.base.restoreSelection();
this.execAction(this.action, opts);
this.base.checkSelection();
},
ensureEncodedUri: function (str) {
return str === decodeURI(str) ? encodeURI(str) : str;
},
ensureEncodedUriComponent: function (str) {
return str === decodeURIComponent(str) ? encodeURIComponent(str) : str;
},
ensureEncodedParam: function (param) {
var split = param.split('='),
key = split[0],
val = split[1];
return key + (val === undefined ? '' : '=' + this.ensureEncodedUriComponent(val));
},
ensureEncodedQuery: function (queryString) {
return queryString.split('&').map(this.ensureEncodedParam.bind(this)).join('&');
},
checkLinkFormat: function (value) {
// Matches any alphabetical characters followed by ://
// Matches protocol relative "//"
// Matches common external protocols "mailto:" "tel:" "maps:"
// Matches relative hash link, begins with "#"
var urlSchemeRegex = /^([a-z]+:)?\/\/|^(mailto|tel|maps):|^\#/i,
hasScheme = urlSchemeRegex.test(value),
scheme = '',
// telRegex is a regex for checking if the string is a telephone number
telRegex = /^\+?\s?\(?(?:\d\s?\-?\)?){3,20}$/,
urlParts = value.match(/^(.*?)(?:\?(.*?))?(?:#(.*))?$/),
path = urlParts[1],
query = urlParts[2],
fragment = urlParts[3];
if (telRegex.test(value)) {
return 'tel:' + value;
}
if (!hasScheme) {
var host = path.split('/')[0];
// if the host part of the path looks like a hostname
if (host.match(/.+(\.|:).+/) || host === 'localhost') {
scheme = 'http://';
}
}
return scheme +
// Ensure path is encoded
this.ensureEncodedUri(path) +
// Ensure query is encoded
(query === undefined ? '' : '?' + this.ensureEncodedQuery(query)) +
// Include fragment unencoded as encodeUriComponent is too
// heavy handed for the many characters allowed in a fragment
(fragment === undefined ? '' : '#' + fragment);
},
doFormCancel: function () {
this.base.restoreSelection();
this.base.checkSelection();
},
// form creation and event handling
attachFormEvents: function (form) {
var close = form.querySelector('.medium-editor-toolbar-close'),
save = form.querySelector('.medium-editor-toolbar-save'),
input = form.querySelector('.medium-editor-toolbar-input');
// Handle clicks on the form itself
this.on(form, 'click', this.handleFormClick.bind(this));
// Handle typing in the textbox
this.on(input, 'keyup', this.handleTextboxKeyup.bind(this));
// Handle close button clicks
this.on(close, 'click', this.handleCloseClick.bind(this));
// Handle save button clicks (capture)
this.on(save, 'click', this.handleSaveClick.bind(this), true);
},
createForm: function () {
var doc = this.document,
form = doc.createElement('div');
// Anchor Form (div)
form.className = 'medium-editor-toolbar-form';
form.id = 'medium-editor-toolbar-form-anchor-' + this.getEditorId();
form.innerHTML = this.getTemplate();
this.attachFormEvents(form);
return form;
},
getInput: function () {
return this.getForm().querySelector('input.medium-editor-toolbar-input');
},
getAnchorTargetCheckbox: function () {
return this.getForm().querySelector('.medium-editor-toolbar-anchor-target');
},
getAnchorButtonCheckbox: function () {
return this.getForm().querySelector('.medium-editor-toolbar-anchor-button');
},
handleTextboxKeyup: function (event) {
// For ENTER -> create the anchor
if (event.keyCode === MediumEditor.util.keyCode.ENTER) {
event.preventDefault();
this.doFormSave();
return;
}
// For ESCAPE -> close the form
if (event.keyCode === MediumEditor.util.keyCode.ESCAPE) {
event.preventDefault();
this.doFormCancel();
}
},
handleFormClick: function (event) {
// make sure not to hide form when clicking inside the form
event.stopPropagation();
},
handleSaveClick: function (event) {
// Clicking Save -> create the anchor
event.preventDefault();
this.doFormSave();
},
handleCloseClick: function (event) {
// Click Close -> close the form
event.preventDefault();
this.doFormCancel();
}
});
MediumEditor.extensions.anchor = AnchorForm;
}());