7894 lines
307 KiB
JavaScript
7894 lines
307 KiB
JavaScript
|
/*global self, document, DOMException */
|
||
|
|
||
|
/*! @source http://purl.eligrey.com/github/classList.js/blob/master/classList.js */
|
||
|
|
||
|
// Full polyfill for browsers with no classList support
|
||
|
if (!("classList" in document.createElement("_"))) {
|
||
|
(function (view) {
|
||
|
|
||
|
"use strict";
|
||
|
|
||
|
if (!('Element' in view)) return;
|
||
|
|
||
|
var
|
||
|
classListProp = "classList"
|
||
|
, protoProp = "prototype"
|
||
|
, elemCtrProto = view.Element[protoProp]
|
||
|
, objCtr = Object
|
||
|
, strTrim = String[protoProp].trim || function () {
|
||
|
return this.replace(/^\s+|\s+$/g, "");
|
||
|
}
|
||
|
, arrIndexOf = Array[protoProp].indexOf || function (item) {
|
||
|
var
|
||
|
i = 0
|
||
|
, len = this.length
|
||
|
;
|
||
|
for (; i < len; i++) {
|
||
|
if (i in this && this[i] === item) {
|
||
|
return i;
|
||
|
}
|
||
|
}
|
||
|
return -1;
|
||
|
}
|
||
|
// Vendors: please allow content code to instantiate DOMExceptions
|
||
|
, DOMEx = function (type, message) {
|
||
|
this.name = type;
|
||
|
this.code = DOMException[type];
|
||
|
this.message = message;
|
||
|
}
|
||
|
, checkTokenAndGetIndex = function (classList, token) {
|
||
|
if (token === "") {
|
||
|
throw new DOMEx(
|
||
|
"SYNTAX_ERR"
|
||
|
, "An invalid or illegal string was specified"
|
||
|
);
|
||
|
}
|
||
|
if (/\s/.test(token)) {
|
||
|
throw new DOMEx(
|
||
|
"INVALID_CHARACTER_ERR"
|
||
|
, "String contains an invalid character"
|
||
|
);
|
||
|
}
|
||
|
return arrIndexOf.call(classList, token);
|
||
|
}
|
||
|
, ClassList = function (elem) {
|
||
|
var
|
||
|
trimmedClasses = strTrim.call(elem.getAttribute("class") || "")
|
||
|
, classes = trimmedClasses ? trimmedClasses.split(/\s+/) : []
|
||
|
, i = 0
|
||
|
, len = classes.length
|
||
|
;
|
||
|
for (; i < len; i++) {
|
||
|
this.push(classes[i]);
|
||
|
}
|
||
|
this._updateClassName = function () {
|
||
|
elem.setAttribute("class", this.toString());
|
||
|
};
|
||
|
}
|
||
|
, classListProto = ClassList[protoProp] = []
|
||
|
, classListGetter = function () {
|
||
|
return new ClassList(this);
|
||
|
}
|
||
|
;
|
||
|
// Most DOMException implementations don't allow calling DOMException's toString()
|
||
|
// on non-DOMExceptions. Error's toString() is sufficient here.
|
||
|
DOMEx[protoProp] = Error[protoProp];
|
||
|
classListProto.item = function (i) {
|
||
|
return this[i] || null;
|
||
|
};
|
||
|
classListProto.contains = function (token) {
|
||
|
token += "";
|
||
|
return checkTokenAndGetIndex(this, token) !== -1;
|
||
|
};
|
||
|
classListProto.add = function () {
|
||
|
var
|
||
|
tokens = arguments
|
||
|
, i = 0
|
||
|
, l = tokens.length
|
||
|
, token
|
||
|
, updated = false
|
||
|
;
|
||
|
do {
|
||
|
token = tokens[i] + "";
|
||
|
if (checkTokenAndGetIndex(this, token) === -1) {
|
||
|
this.push(token);
|
||
|
updated = true;
|
||
|
}
|
||
|
}
|
||
|
while (++i < l);
|
||
|
|
||
|
if (updated) {
|
||
|
this._updateClassName();
|
||
|
}
|
||
|
};
|
||
|
classListProto.remove = function () {
|
||
|
var
|
||
|
tokens = arguments
|
||
|
, i = 0
|
||
|
, l = tokens.length
|
||
|
, token
|
||
|
, updated = false
|
||
|
, index
|
||
|
;
|
||
|
do {
|
||
|
token = tokens[i] + "";
|
||
|
index = checkTokenAndGetIndex(this, token);
|
||
|
while (index !== -1) {
|
||
|
this.splice(index, 1);
|
||
|
updated = true;
|
||
|
index = checkTokenAndGetIndex(this, token);
|
||
|
}
|
||
|
}
|
||
|
while (++i < l);
|
||
|
|
||
|
if (updated) {
|
||
|
this._updateClassName();
|
||
|
}
|
||
|
};
|
||
|
classListProto.toggle = function (token, force) {
|
||
|
token += "";
|
||
|
|
||
|
var
|
||
|
result = this.contains(token)
|
||
|
, method = result ?
|
||
|
force !== true && "remove"
|
||
|
:
|
||
|
force !== false && "add"
|
||
|
;
|
||
|
|
||
|
if (method) {
|
||
|
this[method](token);
|
||
|
}
|
||
|
|
||
|
if (force === true || force === false) {
|
||
|
return force;
|
||
|
} else {
|
||
|
return !result;
|
||
|
}
|
||
|
};
|
||
|
classListProto.toString = function () {
|
||
|
return this.join(" ");
|
||
|
};
|
||
|
|
||
|
if (objCtr.defineProperty) {
|
||
|
var classListPropDesc = {
|
||
|
get: classListGetter
|
||
|
, enumerable: true
|
||
|
, configurable: true
|
||
|
};
|
||
|
try {
|
||
|
objCtr.defineProperty(elemCtrProto, classListProp, classListPropDesc);
|
||
|
} catch (ex) { // IE 8 doesn't support enumerable:true
|
||
|
if (ex.number === -0x7FF5EC54) {
|
||
|
classListPropDesc.enumerable = false;
|
||
|
objCtr.defineProperty(elemCtrProto, classListProp, classListPropDesc);
|
||
|
}
|
||
|
}
|
||
|
} else if (objCtr[protoProp].__defineGetter__) {
|
||
|
elemCtrProto.__defineGetter__(classListProp, classListGetter);
|
||
|
}
|
||
|
|
||
|
}(self));
|
||
|
}
|
||
|
|
||
|
/* Blob.js
|
||
|
* A Blob implementation.
|
||
|
* 2014-07-24
|
||
|
*
|
||
|
* By Eli Grey, http://eligrey.com
|
||
|
* By Devin Samarin, https://github.com/dsamarin
|
||
|
* License: X11/MIT
|
||
|
* See https://github.com/eligrey/Blob.js/blob/master/LICENSE.md
|
||
|
*/
|
||
|
|
||
|
/*global self, unescape */
|
||
|
/*jslint bitwise: true, regexp: true, confusion: true, es5: true, vars: true, white: true,
|
||
|
plusplus: true */
|
||
|
|
||
|
/*! @source http://purl.eligrey.com/github/Blob.js/blob/master/Blob.js */
|
||
|
|
||
|
(function (view) {
|
||
|
"use strict";
|
||
|
|
||
|
view.URL = view.URL || view.webkitURL;
|
||
|
|
||
|
if (view.Blob && view.URL) {
|
||
|
try {
|
||
|
new Blob;
|
||
|
return;
|
||
|
} catch (e) {}
|
||
|
}
|
||
|
|
||
|
// Internally we use a BlobBuilder implementation to base Blob off of
|
||
|
// in order to support older browsers that only have BlobBuilder
|
||
|
var BlobBuilder = view.BlobBuilder || view.WebKitBlobBuilder || view.MozBlobBuilder || (function(view) {
|
||
|
var
|
||
|
get_class = function(object) {
|
||
|
return Object.prototype.toString.call(object).match(/^\[object\s(.*)\]$/)[1];
|
||
|
}
|
||
|
, FakeBlobBuilder = function BlobBuilder() {
|
||
|
this.data = [];
|
||
|
}
|
||
|
, FakeBlob = function Blob(data, type, encoding) {
|
||
|
this.data = data;
|
||
|
this.size = data.length;
|
||
|
this.type = type;
|
||
|
this.encoding = encoding;
|
||
|
}
|
||
|
, FBB_proto = FakeBlobBuilder.prototype
|
||
|
, FB_proto = FakeBlob.prototype
|
||
|
, FileReaderSync = view.FileReaderSync
|
||
|
, FileException = function(type) {
|
||
|
this.code = this[this.name = type];
|
||
|
}
|
||
|
, file_ex_codes = (
|
||
|
"NOT_FOUND_ERR SECURITY_ERR ABORT_ERR NOT_READABLE_ERR ENCODING_ERR "
|
||
|
+ "NO_MODIFICATION_ALLOWED_ERR INVALID_STATE_ERR SYNTAX_ERR"
|
||
|
).split(" ")
|
||
|
, file_ex_code = file_ex_codes.length
|
||
|
, real_URL = view.URL || view.webkitURL || view
|
||
|
, real_create_object_URL = real_URL.createObjectURL
|
||
|
, real_revoke_object_URL = real_URL.revokeObjectURL
|
||
|
, URL = real_URL
|
||
|
, btoa = view.btoa
|
||
|
, atob = view.atob
|
||
|
|
||
|
, ArrayBuffer = view.ArrayBuffer
|
||
|
, Uint8Array = view.Uint8Array
|
||
|
|
||
|
, origin = /^[\w-]+:\/*\[?[\w\.:-]+\]?(?::[0-9]+)?/
|
||
|
;
|
||
|
FakeBlob.fake = FB_proto.fake = true;
|
||
|
while (file_ex_code--) {
|
||
|
FileException.prototype[file_ex_codes[file_ex_code]] = file_ex_code + 1;
|
||
|
}
|
||
|
// Polyfill URL
|
||
|
if (!real_URL.createObjectURL) {
|
||
|
URL = view.URL = function(uri) {
|
||
|
var
|
||
|
uri_info = document.createElementNS("http://www.w3.org/1999/xhtml", "a")
|
||
|
, uri_origin
|
||
|
;
|
||
|
uri_info.href = uri;
|
||
|
if (!("origin" in uri_info)) {
|
||
|
if (uri_info.protocol.toLowerCase() === "data:") {
|
||
|
uri_info.origin = null;
|
||
|
} else {
|
||
|
uri_origin = uri.match(origin);
|
||
|
uri_info.origin = uri_origin && uri_origin[1];
|
||
|
}
|
||
|
}
|
||
|
return uri_info;
|
||
|
};
|
||
|
}
|
||
|
URL.createObjectURL = function(blob) {
|
||
|
var
|
||
|
type = blob.type
|
||
|
, data_URI_header
|
||
|
;
|
||
|
if (type === null) {
|
||
|
type = "application/octet-stream";
|
||
|
}
|
||
|
if (blob instanceof FakeBlob) {
|
||
|
data_URI_header = "data:" + type;
|
||
|
if (blob.encoding === "base64") {
|
||
|
return data_URI_header + ";base64," + blob.data;
|
||
|
} else if (blob.encoding === "URI") {
|
||
|
return data_URI_header + "," + decodeURIComponent(blob.data);
|
||
|
} if (btoa) {
|
||
|
return data_URI_header + ";base64," + btoa(blob.data);
|
||
|
} else {
|
||
|
return data_URI_header + "," + encodeURIComponent(blob.data);
|
||
|
}
|
||
|
} else if (real_create_object_URL) {
|
||
|
return real_create_object_URL.call(real_URL, blob);
|
||
|
}
|
||
|
};
|
||
|
URL.revokeObjectURL = function(object_URL) {
|
||
|
if (object_URL.substring(0, 5) !== "data:" && real_revoke_object_URL) {
|
||
|
real_revoke_object_URL.call(real_URL, object_URL);
|
||
|
}
|
||
|
};
|
||
|
FBB_proto.append = function(data/*, endings*/) {
|
||
|
var bb = this.data;
|
||
|
// decode data to a binary string
|
||
|
if (Uint8Array && (data instanceof ArrayBuffer || data instanceof Uint8Array)) {
|
||
|
var
|
||
|
str = ""
|
||
|
, buf = new Uint8Array(data)
|
||
|
, i = 0
|
||
|
, buf_len = buf.length
|
||
|
;
|
||
|
for (; i < buf_len; i++) {
|
||
|
str += String.fromCharCode(buf[i]);
|
||
|
}
|
||
|
bb.push(str);
|
||
|
} else if (get_class(data) === "Blob" || get_class(data) === "File") {
|
||
|
if (FileReaderSync) {
|
||
|
var fr = new FileReaderSync;
|
||
|
bb.push(fr.readAsBinaryString(data));
|
||
|
} else {
|
||
|
// async FileReader won't work as BlobBuilder is sync
|
||
|
throw new FileException("NOT_READABLE_ERR");
|
||
|
}
|
||
|
} else if (data instanceof FakeBlob) {
|
||
|
if (data.encoding === "base64" && atob) {
|
||
|
bb.push(atob(data.data));
|
||
|
} else if (data.encoding === "URI") {
|
||
|
bb.push(decodeURIComponent(data.data));
|
||
|
} else if (data.encoding === "raw") {
|
||
|
bb.push(data.data);
|
||
|
}
|
||
|
} else {
|
||
|
if (typeof data !== "string") {
|
||
|
data += ""; // convert unsupported types to strings
|
||
|
}
|
||
|
// decode UTF-16 to binary string
|
||
|
bb.push(unescape(encodeURIComponent(data)));
|
||
|
}
|
||
|
};
|
||
|
FBB_proto.getBlob = function(type) {
|
||
|
if (!arguments.length) {
|
||
|
type = null;
|
||
|
}
|
||
|
return new FakeBlob(this.data.join(""), type, "raw");
|
||
|
};
|
||
|
FBB_proto.toString = function() {
|
||
|
return "[object BlobBuilder]";
|
||
|
};
|
||
|
FB_proto.slice = function(start, end, type) {
|
||
|
var args = arguments.length;
|
||
|
if (args < 3) {
|
||
|
type = null;
|
||
|
}
|
||
|
return new FakeBlob(
|
||
|
this.data.slice(start, args > 1 ? end : this.data.length)
|
||
|
, type
|
||
|
, this.encoding
|
||
|
);
|
||
|
};
|
||
|
FB_proto.toString = function() {
|
||
|
return "[object Blob]";
|
||
|
};
|
||
|
FB_proto.close = function() {
|
||
|
this.size = 0;
|
||
|
delete this.data;
|
||
|
};
|
||
|
return FakeBlobBuilder;
|
||
|
}(view));
|
||
|
|
||
|
view.Blob = function(blobParts, options) {
|
||
|
var type = options ? (options.type || "") : "";
|
||
|
var builder = new BlobBuilder();
|
||
|
if (blobParts) {
|
||
|
for (var i = 0, len = blobParts.length; i < len; i++) {
|
||
|
if (Uint8Array && blobParts[i] instanceof Uint8Array) {
|
||
|
builder.append(blobParts[i].buffer);
|
||
|
}
|
||
|
else {
|
||
|
builder.append(blobParts[i]);
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
var blob = builder.getBlob(type);
|
||
|
if (!blob.slice && blob.webkitSlice) {
|
||
|
blob.slice = blob.webkitSlice;
|
||
|
}
|
||
|
return blob;
|
||
|
};
|
||
|
|
||
|
var getPrototypeOf = Object.getPrototypeOf || function(object) {
|
||
|
return object.__proto__;
|
||
|
};
|
||
|
view.Blob.prototype = getPrototypeOf(new view.Blob());
|
||
|
}(typeof self !== "undefined" && self || typeof window !== "undefined" && window || this.content || this));
|
||
|
|
||
|
(function (root, factory) {
|
||
|
'use strict';
|
||
|
var isElectron = typeof module === 'object' && typeof process !== 'undefined' && process && process.versions && process.versions.electron;
|
||
|
if (!isElectron && typeof module === 'object') {
|
||
|
module.exports = factory;
|
||
|
} else if (typeof define === 'function' && define.amd) {
|
||
|
define(function () {
|
||
|
return factory;
|
||
|
});
|
||
|
} else {
|
||
|
root.MediumEditor = factory;
|
||
|
}
|
||
|
}(this, function () {
|
||
|
|
||
|
'use strict';
|
||
|
|
||
|
function MediumEditor(elements, options) {
|
||
|
'use strict';
|
||
|
return this.init(elements, options);
|
||
|
}
|
||
|
|
||
|
MediumEditor.extensions = {};
|
||
|
/*jshint unused: true */
|
||
|
(function (window) {
|
||
|
'use strict';
|
||
|
|
||
|
function copyInto(overwrite, dest) {
|
||
|
var prop,
|
||
|
sources = Array.prototype.slice.call(arguments, 2);
|
||
|
dest = dest || {};
|
||
|
for (var i = 0; i < sources.length; i++) {
|
||
|
var source = sources[i];
|
||
|
if (source) {
|
||
|
for (prop in source) {
|
||
|
if (source.hasOwnProperty(prop) &&
|
||
|
typeof source[prop] !== 'undefined' &&
|
||
|
(overwrite || dest.hasOwnProperty(prop) === false)) {
|
||
|
dest[prop] = source[prop];
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
return dest;
|
||
|
}
|
||
|
|
||
|
// https://developer.mozilla.org/en-US/docs/Web/API/Node/contains
|
||
|
// Some browsers (including phantom) don't return true for Node.contains(child)
|
||
|
// if child is a text node. Detect these cases here and use a fallback
|
||
|
// for calls to Util.isDescendant()
|
||
|
var nodeContainsWorksWithTextNodes = false;
|
||
|
try {
|
||
|
var testParent = document.createElement('div'),
|
||
|
testText = document.createTextNode(' ');
|
||
|
testParent.appendChild(testText);
|
||
|
nodeContainsWorksWithTextNodes = testParent.contains(testText);
|
||
|
} catch (exc) {}
|
||
|
|
||
|
var Util = {
|
||
|
|
||
|
// http://stackoverflow.com/questions/17907445/how-to-detect-ie11#comment30165888_17907562
|
||
|
// by rg89
|
||
|
isIE: ((navigator.appName === 'Microsoft Internet Explorer') || ((navigator.appName === 'Netscape') && (new RegExp('Trident/.*rv:([0-9]{1,}[.0-9]{0,})').exec(navigator.userAgent) !== null))),
|
||
|
|
||
|
isEdge: (/Edge\/\d+/).exec(navigator.userAgent) !== null,
|
||
|
|
||
|
// if firefox
|
||
|
isFF: (navigator.userAgent.toLowerCase().indexOf('firefox') > -1),
|
||
|
|
||
|
// http://stackoverflow.com/a/11752084/569101
|
||
|
isMac: (window.navigator.platform.toUpperCase().indexOf('MAC') >= 0),
|
||
|
|
||
|
// https://github.com/jashkenas/underscore
|
||
|
// Lonely letter MUST USE the uppercase code
|
||
|
keyCode: {
|
||
|
BACKSPACE: 8,
|
||
|
TAB: 9,
|
||
|
ENTER: 13,
|
||
|
ESCAPE: 27,
|
||
|
SPACE: 32,
|
||
|
DELETE: 46,
|
||
|
K: 75, // K keycode, and not k
|
||
|
M: 77,
|
||
|
V: 86
|
||
|
},
|
||
|
|
||
|
/**
|
||
|
* Returns true if it's metaKey on Mac, or ctrlKey on non-Mac.
|
||
|
* See #591
|
||
|
*/
|
||
|
isMetaCtrlKey: function (event) {
|
||
|
if ((Util.isMac && event.metaKey) || (!Util.isMac && event.ctrlKey)) {
|
||
|
return true;
|
||
|
}
|
||
|
|
||
|
return false;
|
||
|
},
|
||
|
|
||
|
/**
|
||
|
* Returns true if the key associated to the event is inside keys array
|
||
|
*
|
||
|
* @see : https://github.com/jquery/jquery/blob/0705be475092aede1eddae01319ec931fb9c65fc/src/event.js#L473-L484
|
||
|
* @see : http://stackoverflow.com/q/4471582/569101
|
||
|
*/
|
||
|
isKey: function (event, keys) {
|
||
|
var keyCode = Util.getKeyCode(event);
|
||
|
|
||
|
// it's not an array let's just compare strings!
|
||
|
if (false === Array.isArray(keys)) {
|
||
|
return keyCode === keys;
|
||
|
}
|
||
|
|
||
|
if (-1 === keys.indexOf(keyCode)) {
|
||
|
return false;
|
||
|
}
|
||
|
|
||
|
return true;
|
||
|
},
|
||
|
|
||
|
getKeyCode: function (event) {
|
||
|
var keyCode = event.which;
|
||
|
|
||
|
// getting the key code from event
|
||
|
if (null === keyCode) {
|
||
|
keyCode = event.charCode !== null ? event.charCode : event.keyCode;
|
||
|
}
|
||
|
|
||
|
return keyCode;
|
||
|
},
|
||
|
|
||
|
blockContainerElementNames: [
|
||
|
// elements our editor generates
|
||
|
'p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'blockquote', 'pre', 'ul', 'li', 'ol',
|
||
|
// all other known block elements
|
||
|
'address', 'article', 'aside', 'audio', 'canvas', 'dd', 'dl', 'dt', 'fieldset',
|
||
|
'figcaption', 'figure', 'footer', 'form', 'header', 'hgroup', 'main', 'nav',
|
||
|
'noscript', 'output', 'section', 'video',
|
||
|
'table', 'thead', 'tbody', 'tfoot', 'tr', 'th', 'td'
|
||
|
],
|
||
|
|
||
|
emptyElementNames: ['br', 'col', 'colgroup', 'hr', 'img', 'input', 'source', 'wbr'],
|
||
|
|
||
|
extend: function extend(/* dest, source1, source2, ...*/) {
|
||
|
var args = [true].concat(Array.prototype.slice.call(arguments));
|
||
|
return copyInto.apply(this, args);
|
||
|
},
|
||
|
|
||
|
defaults: function defaults(/*dest, source1, source2, ...*/) {
|
||
|
var args = [false].concat(Array.prototype.slice.call(arguments));
|
||
|
return copyInto.apply(this, args);
|
||
|
},
|
||
|
|
||
|
/*
|
||
|
* Create a link around the provided text nodes which must be adjacent to each other and all be
|
||
|
* descendants of the same closest block container. If the preconditions are not met, unexpected
|
||
|
* behavior will result.
|
||
|
*/
|
||
|
createLink: function (document, textNodes, href, target) {
|
||
|
var anchor = document.createElement('a');
|
||
|
Util.moveTextRangeIntoElement(textNodes[0], textNodes[textNodes.length - 1], anchor);
|
||
|
anchor.setAttribute('href', href);
|
||
|
if (target) {
|
||
|
if (target === '_blank') {
|
||
|
anchor.setAttribute('rel', 'noopener noreferrer');
|
||
|
}
|
||
|
anchor.setAttribute('target', target);
|
||
|
}
|
||
|
return anchor;
|
||
|
},
|
||
|
|
||
|
/*
|
||
|
* Given the provided match in the format {start: 1, end: 2} where start and end are indices into the
|
||
|
* textContent of the provided element argument, modify the DOM inside element to ensure that the text
|
||
|
* identified by the provided match can be returned as text nodes that contain exactly that text, without
|
||
|
* any additional text at the beginning or end of the returned array of adjacent text nodes.
|
||
|
*
|
||
|
* The only DOM manipulation performed by this function is splitting the text nodes, non-text nodes are
|
||
|
* not affected in any way.
|
||
|
*/
|
||
|
findOrCreateMatchingTextNodes: function (document, element, match) {
|
||
|
var treeWalker = document.createTreeWalker(element, NodeFilter.SHOW_ALL, null, false),
|
||
|
matchedNodes = [],
|
||
|
currentTextIndex = 0,
|
||
|
startReached = false,
|
||
|
currentNode = null,
|
||
|
newNode = null;
|
||
|
|
||
|
while ((currentNode = treeWalker.nextNode()) !== null) {
|
||
|
if (currentNode.nodeType > 3) {
|
||
|
continue;
|
||
|
} else if (currentNode.nodeType === 3) {
|
||
|
if (!startReached && match.start < (currentTextIndex + currentNode.nodeValue.length)) {
|
||
|
startReached = true;
|
||
|
newNode = Util.splitStartNodeIfNeeded(currentNode, match.start, currentTextIndex);
|
||
|
}
|
||
|
if (startReached) {
|
||
|
Util.splitEndNodeIfNeeded(currentNode, newNode, match.end, currentTextIndex);
|
||
|
}
|
||
|
if (startReached && currentTextIndex === match.end) {
|
||
|
break; // Found the node(s) corresponding to the link. Break out and move on to the next.
|
||
|
} else if (startReached && currentTextIndex > (match.end + 1)) {
|
||
|
throw new Error('PerformLinking overshot the target!'); // should never happen...
|
||
|
}
|
||
|
|
||
|
if (startReached) {
|
||
|
matchedNodes.push(newNode || currentNode);
|
||
|
}
|
||
|
|
||
|
currentTextIndex += currentNode.nodeValue.length;
|
||
|
if (newNode !== null) {
|
||
|
currentTextIndex += newNode.nodeValue.length;
|
||
|
// Skip the newNode as we'll already have pushed it to the matches
|
||
|
treeWalker.nextNode();
|
||
|
}
|
||
|
newNode = null;
|
||
|
} else if (currentNode.tagName.toLowerCase() === 'img') {
|
||
|
if (!startReached && (match.start <= currentTextIndex)) {
|
||
|
startReached = true;
|
||
|
}
|
||
|
if (startReached) {
|
||
|
matchedNodes.push(currentNode);
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
return matchedNodes;
|
||
|
},
|
||
|
|
||
|
/*
|
||
|
* Given the provided text node and text coordinates, split the text node if needed to make it align
|
||
|
* precisely with the coordinates.
|
||
|
*
|
||
|
* This function is intended to be called from Util.findOrCreateMatchingTextNodes.
|
||
|
*/
|
||
|
splitStartNodeIfNeeded: function (currentNode, matchStartIndex, currentTextIndex) {
|
||
|
if (matchStartIndex !== currentTextIndex) {
|
||
|
return currentNode.splitText(matchStartIndex - currentTextIndex);
|
||
|
}
|
||
|
return null;
|
||
|
},
|
||
|
|
||
|
/*
|
||
|
* Given the provided text node and text coordinates, split the text node if needed to make it align
|
||
|
* precisely with the coordinates. The newNode argument should from the result of Util.splitStartNodeIfNeeded,
|
||
|
* if that function has been called on the same currentNode.
|
||
|
*
|
||
|
* This function is intended to be called from Util.findOrCreateMatchingTextNodes.
|
||
|
*/
|
||
|
splitEndNodeIfNeeded: function (currentNode, newNode, matchEndIndex, currentTextIndex) {
|
||
|
var textIndexOfEndOfFarthestNode,
|
||
|
endSplitPoint;
|
||
|
textIndexOfEndOfFarthestNode = currentTextIndex + currentNode.nodeValue.length +
|
||
|
(newNode ? newNode.nodeValue.length : 0) - 1;
|
||
|
endSplitPoint = matchEndIndex - currentTextIndex -
|
||
|
(newNode ? currentNode.nodeValue.length : 0);
|
||
|
if (textIndexOfEndOfFarthestNode >= matchEndIndex &&
|
||
|
currentTextIndex !== textIndexOfEndOfFarthestNode &&
|
||
|
endSplitPoint !== 0) {
|
||
|
(newNode || currentNode).splitText(endSplitPoint);
|
||
|
}
|
||
|
},
|
||
|
|
||
|
/*
|
||
|
* Take an element, and break up all of its text content into unique pieces such that:
|
||
|
* 1) All text content of the elements are in separate blocks. No piece of text content should span
|
||
|
* across multiple blocks. This means no element return by this function should have
|
||
|
* any blocks as children.
|
||
|
* 2) The union of the textcontent of all of the elements returned here covers all
|
||
|
* of the text within the element.
|
||
|
*
|
||
|
*
|
||
|
* EXAMPLE:
|
||
|
* In the event that we have something like:
|
||
|
*
|
||
|
* <blockquote>
|
||
|
* <p>Some Text</p>
|
||
|
* <ol>
|
||
|
* <li>List Item 1</li>
|
||
|
* <li>List Item 2</li>
|
||
|
* </ol>
|
||
|
* </blockquote>
|
||
|
*
|
||
|
* This function would return these elements as an array:
|
||
|
* [ <p>Some Text</p>, <li>List Item 1</li>, <li>List Item 2</li> ]
|
||
|
*
|
||
|
* Since the <blockquote> and <ol> elements contain blocks within them they are not returned.
|
||
|
* Since the <p> and <li>'s don't contain block elements and cover all the text content of the
|
||
|
* <blockquote> container, they are the elements returned.
|
||
|
*/
|
||
|
splitByBlockElements: function (element) {
|
||
|
if (element.nodeType !== 3 && element.nodeType !== 1) {
|
||
|
return [];
|
||
|
}
|
||
|
|
||
|
var toRet = [],
|
||
|
blockElementQuery = MediumEditor.util.blockContainerElementNames.join(',');
|
||
|
|
||
|
if (element.nodeType === 3 || element.querySelectorAll(blockElementQuery).length === 0) {
|
||
|
return [element];
|
||
|
}
|
||
|
|
||
|
for (var i = 0; i < element.childNodes.length; i++) {
|
||
|
var child = element.childNodes[i];
|
||
|
if (child.nodeType === 3) {
|
||
|
toRet.push(child);
|
||
|
} else if (child.nodeType === 1) {
|
||
|
var blockElements = child.querySelectorAll(blockElementQuery);
|
||
|
if (blockElements.length === 0) {
|
||
|
toRet.push(child);
|
||
|
} else {
|
||
|
toRet = toRet.concat(MediumEditor.util.splitByBlockElements(child));
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
|
||
|
return toRet;
|
||
|
},
|
||
|
|
||
|
// Find the next node in the DOM tree that represents any text that is being
|
||
|
// displayed directly next to the targetNode (passed as an argument)
|
||
|
// Text that appears directly next to the current node can be:
|
||
|
// - A sibling text node
|
||
|
// - A descendant of a sibling element
|
||
|
// - A sibling text node of an ancestor
|
||
|
// - A descendant of a sibling element of an ancestor
|
||
|
findAdjacentTextNodeWithContent: function findAdjacentTextNodeWithContent(rootNode, targetNode, ownerDocument) {
|
||
|
var pastTarget = false,
|
||
|
nextNode,
|
||
|
nodeIterator = ownerDocument.createNodeIterator(rootNode, NodeFilter.SHOW_TEXT, null, false);
|
||
|
|
||
|
// Use a native NodeIterator to iterate over all the text nodes that are descendants
|
||
|
// of the rootNode. Once past the targetNode, choose the first non-empty text node
|
||
|
nextNode = nodeIterator.nextNode();
|
||
|
while (nextNode) {
|
||
|
if (nextNode === targetNode) {
|
||
|
pastTarget = true;
|
||
|
} else if (pastTarget) {
|
||
|
if (nextNode.nodeType === 3 && nextNode.nodeValue && nextNode.nodeValue.trim().length > 0) {
|
||
|
break;
|
||
|
}
|
||
|
}
|
||
|
nextNode = nodeIterator.nextNode();
|
||
|
}
|
||
|
|
||
|
return nextNode;
|
||
|
},
|
||
|
|
||
|
// Find an element's previous sibling within a medium-editor element
|
||
|
// If one doesn't exist, find the closest ancestor's previous sibling
|
||
|
findPreviousSibling: function (node) {
|
||
|
if (!node || Util.isMediumEditorElement(node)) {
|
||
|
return false;
|
||
|
}
|
||
|
|
||
|
var previousSibling = node.previousSibling;
|
||
|
while (!previousSibling && !Util.isMediumEditorElement(node.parentNode)) {
|
||
|
node = node.parentNode;
|
||
|
previousSibling = node.previousSibling;
|
||
|
}
|
||
|
|
||
|
return previousSibling;
|
||
|
},
|
||
|
|
||
|
isDescendant: function isDescendant(parent, child, checkEquality) {
|
||
|
if (!parent || !child) {
|
||
|
return false;
|
||
|
}
|
||
|
if (parent === child) {
|
||
|
return !!checkEquality;
|
||
|
}
|
||
|
// If parent is not an element, it can't have any descendants
|
||
|
if (parent.nodeType !== 1) {
|
||
|
return false;
|
||
|
}
|
||
|
if (nodeContainsWorksWithTextNodes || child.nodeType !== 3) {
|
||
|
return parent.contains(child);
|
||
|
}
|
||
|
var node = child.parentNode;
|
||
|
while (node !== null) {
|
||
|
if (node === parent) {
|
||
|
return true;
|
||
|
}
|
||
|
node = node.parentNode;
|
||
|
}
|
||
|
return false;
|
||
|
},
|
||
|
|
||
|
// https://github.com/jashkenas/underscore
|
||
|
isElement: function isElement(obj) {
|
||
|
return !!(obj && obj.nodeType === 1);
|
||
|
},
|
||
|
|
||
|
// https://github.com/jashkenas/underscore
|
||
|
throttle: function (func, wait) {
|
||
|
var THROTTLE_INTERVAL = 50,
|
||
|
context,
|
||
|
args,
|
||
|
result,
|
||
|
timeout = null,
|
||
|
previous = 0,
|
||
|
later = function () {
|
||
|
previous = Date.now();
|
||
|
timeout = null;
|
||
|
result = func.apply(context, args);
|
||
|
if (!timeout) {
|
||
|
context = args = null;
|
||
|
}
|
||
|
};
|
||
|
|
||
|
if (!wait && wait !== 0) {
|
||
|
wait = THROTTLE_INTERVAL;
|
||
|
}
|
||
|
|
||
|
return function () {
|
||
|
var now = Date.now(),
|
||
|
remaining = wait - (now - previous);
|
||
|
|
||
|
context = this;
|
||
|
args = arguments;
|
||
|
if (remaining <= 0 || remaining > wait) {
|
||
|
if (timeout) {
|
||
|
clearTimeout(timeout);
|
||
|
timeout = null;
|
||
|
}
|
||
|
previous = now;
|
||
|
result = func.apply(context, args);
|
||
|
if (!timeout) {
|
||
|
context = args = null;
|
||
|
}
|
||
|
} else if (!timeout) {
|
||
|
timeout = setTimeout(later, remaining);
|
||
|
}
|
||
|
return result;
|
||
|
};
|
||
|
},
|
||
|
|
||
|
traverseUp: function (current, testElementFunction) {
|
||
|
if (!current) {
|
||
|
return false;
|
||
|
}
|
||
|
|
||
|
do {
|
||
|
if (current.nodeType === 1) {
|
||
|
if (testElementFunction(current)) {
|
||
|
return current;
|
||
|
}
|
||
|
// do not traverse upwards past the nearest containing editor
|
||
|
if (Util.isMediumEditorElement(current)) {
|
||
|
return false;
|
||
|
}
|
||
|
}
|
||
|
|
||
|
current = current.parentNode;
|
||
|
} while (current);
|
||
|
|
||
|
return false;
|
||
|
},
|
||
|
|
||
|
htmlEntities: function (str) {
|
||
|
// converts special characters (like <) into their escaped/encoded values (like <).
|
||
|
// This allows you to show to display the string without the browser reading it as HTML.
|
||
|
return String(str).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||
|
},
|
||
|
|
||
|
// http://stackoverflow.com/questions/6690752/insert-html-at-caret-in-a-contenteditable-div
|
||
|
insertHTMLCommand: function (doc, html) {
|
||
|
var selection, range, el, fragment, node, lastNode, toReplace,
|
||
|
res = false,
|
||
|
ecArgs = ['insertHTML', false, html];
|
||
|
|
||
|
/* Edge's implementation of insertHTML is just buggy right now:
|
||
|
* - Doesn't allow leading white space at the beginning of an element
|
||
|
* - Found a case when a <font size="2"> tag was inserted when calling alignCenter inside a blockquote
|
||
|
*
|
||
|
* There are likely other bugs, these are just the ones we found so far.
|
||
|
* For now, let's just use the same fallback we did for IE
|
||
|
*/
|
||
|
if (!MediumEditor.util.isEdge && doc.queryCommandSupported('insertHTML')) {
|
||
|
try {
|
||
|
return doc.execCommand.apply(doc, ecArgs);
|
||
|
} catch (ignore) {}
|
||
|
}
|
||
|
|
||
|
selection = doc.getSelection();
|
||
|
if (selection.rangeCount) {
|
||
|
range = selection.getRangeAt(0);
|
||
|
toReplace = range.commonAncestorContainer;
|
||
|
|
||
|
// https://github.com/yabwe/medium-editor/issues/748
|
||
|
// If the selection is an empty editor element, create a temporary text node inside of the editor
|
||
|
// and select it so that we don't delete the editor element
|
||
|
if (Util.isMediumEditorElement(toReplace) && !toReplace.firstChild) {
|
||
|
range.selectNode(toReplace.appendChild(doc.createTextNode('')));
|
||
|
} else if ((toReplace.nodeType === 3 && range.startOffset === 0 && range.endOffset === toReplace.nodeValue.length) ||
|
||
|
(toReplace.nodeType !== 3 && toReplace.innerHTML === range.toString())) {
|
||
|
// Ensure range covers maximum amount of nodes as possible
|
||
|
// By moving up the DOM and selecting ancestors whose only child is the range
|
||
|
while (!Util.isMediumEditorElement(toReplace) &&
|
||
|
toReplace.parentNode &&
|
||
|
toReplace.parentNode.childNodes.length === 1 &&
|
||
|
!Util.isMediumEditorElement(toReplace.parentNode)) {
|
||
|
toReplace = toReplace.parentNode;
|
||
|
}
|
||
|
range.selectNode(toReplace);
|
||
|
}
|
||
|
range.deleteContents();
|
||
|
|
||
|
el = doc.createElement('div');
|
||
|
el.innerHTML = html;
|
||
|
fragment = doc.createDocumentFragment();
|
||
|
while (el.firstChild) {
|
||
|
node = el.firstChild;
|
||
|
lastNode = fragment.appendChild(node);
|
||
|
}
|
||
|
range.insertNode(fragment);
|
||
|
|
||
|
// Preserve the selection:
|
||
|
if (lastNode) {
|
||
|
range = range.cloneRange();
|
||
|
range.setStartAfter(lastNode);
|
||
|
range.collapse(true);
|
||
|
MediumEditor.selection.selectRange(doc, range);
|
||
|
}
|
||
|
res = true;
|
||
|
}
|
||
|
|
||
|
// https://github.com/yabwe/medium-editor/issues/992
|
||
|
// If we're monitoring calls to execCommand, notify listeners as if a real call had happened
|
||
|
if (doc.execCommand.callListeners) {
|
||
|
doc.execCommand.callListeners(ecArgs, res);
|
||
|
}
|
||
|
return res;
|
||
|
},
|
||
|
|
||
|
execFormatBlock: function (doc, tagName) {
|
||
|
// Get the top level block element that contains the selection
|
||
|
var blockContainer = Util.getTopBlockContainer(MediumEditor.selection.getSelectionStart(doc)),
|
||
|
childNodes;
|
||
|
|
||
|
// Special handling for blockquote
|
||
|
if (tagName === 'blockquote') {
|
||
|
if (blockContainer) {
|
||
|
childNodes = Array.prototype.slice.call(blockContainer.childNodes);
|
||
|
// Check if the blockquote has a block element as a child (nested blocks)
|
||
|
if (childNodes.some(function (childNode) {
|
||
|
return Util.isBlockContainer(childNode);
|
||
|
})) {
|
||
|
// FF handles blockquote differently on formatBlock
|
||
|
// allowing nesting, we need to use outdent
|
||
|
// https://developer.mozilla.org/en-US/docs/Rich-Text_Editing_in_Mozilla
|
||
|
return doc.execCommand('outdent', false, null);
|
||
|
}
|
||
|
}
|
||
|
|
||
|
// When IE blockquote needs to be called as indent
|
||
|
// http://stackoverflow.com/questions/1816223/rich-text-editor-with-blockquote-function/1821777#1821777
|
||
|
if (Util.isIE) {
|
||
|
return doc.execCommand('indent', false, tagName);
|
||
|
}
|
||
|
}
|
||
|
|
||
|
// If the blockContainer is already the element type being passed in
|
||
|
// treat it as 'undo' formatting and just convert it to a <p>
|
||
|
if (blockContainer && tagName === blockContainer.nodeName.toLowerCase()) {
|
||
|
tagName = 'p';
|
||
|
}
|
||
|
|
||
|
// When IE we need to add <> to heading elements
|
||
|
// http://stackoverflow.com/questions/10741831/execcommand-formatblock-headings-in-ie
|
||
|
if (Util.isIE) {
|
||
|
tagName = '<' + tagName + '>';
|
||
|
}
|
||
|
|
||
|
// When FF, IE and Edge, we have to handle blockquote node seperately as 'formatblock' does not work.
|
||
|
// https://developer.mozilla.org/en-US/docs/Web/API/Document/execCommand#Commands
|
||
|
if (blockContainer && blockContainer.nodeName.toLowerCase() === 'blockquote') {
|
||
|
// For IE, just use outdent
|
||
|
if (Util.isIE && tagName === '<p>') {
|
||
|
return doc.execCommand('outdent', false, tagName);
|
||
|
}
|
||
|
|
||
|
// For Firefox and Edge, make sure there's a nested block element before calling outdent
|
||
|
if ((Util.isFF || Util.isEdge) && tagName === 'p') {
|
||
|
childNodes = Array.prototype.slice.call(blockContainer.childNodes);
|
||
|
// If there are some non-block elements we need to wrap everything in a <p> before we outdent
|
||
|
if (childNodes.some(function (childNode) {
|
||
|
return !Util.isBlockContainer(childNode);
|
||
|
})) {
|
||
|
doc.execCommand('formatBlock', false, tagName);
|
||
|
}
|
||
|
return doc.execCommand('outdent', false, tagName);
|
||
|
}
|
||
|
}
|
||
|
|
||
|
return doc.execCommand('formatBlock', false, tagName);
|
||
|
},
|
||
|
|
||
|
/**
|
||
|
* Set target to blank on the given el element
|
||
|
*
|
||
|
* TODO: not sure if this should be here
|
||
|
*
|
||
|
* When creating a link (using core -> createLink) the selection returned by Firefox will be the parent of the created link
|
||
|
* instead of the created link itself (as it is for Chrome for example), so we retrieve all "a" children to grab the good one by
|
||
|
* using `anchorUrl` to ensure that we are adding target="_blank" on the good one.
|
||
|
* This isn't a bulletproof solution anyway ..
|
||
|
*/
|
||
|
setTargetBlank: function (el, anchorUrl) {
|
||
|
var i, url = anchorUrl || false;
|
||
|
if (el.nodeName.toLowerCase() === 'a') {
|
||
|
el.target = '_blank';
|
||
|
el.rel = 'noopener noreferrer';
|
||
|
} else {
|
||
|
el = el.getElementsByTagName('a');
|
||
|
|
||
|
for (i = 0; i < el.length; i += 1) {
|
||
|
if (false === url || url === el[i].attributes.href.value) {
|
||
|
el[i].target = '_blank';
|
||
|
el[i].rel = 'noopener noreferrer';
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
},
|
||
|
|
||
|
/*
|
||
|
* this function is called to explicitly remove the target='_blank' as FF holds on to _blank value even
|
||
|
* after unchecking the checkbox on anchor form
|
||
|
*/
|
||
|
removeTargetBlank: function (el, anchorUrl) {
|
||
|
var i;
|
||
|
if (el.nodeName.toLowerCase() === 'a') {
|
||
|
el.removeAttribute('target');
|
||
|
el.removeAttribute('rel');
|
||
|
} else {
|
||
|
el = el.getElementsByTagName('a');
|
||
|
|
||
|
for (i = 0; i < el.length; i += 1) {
|
||
|
if (anchorUrl === el[i].attributes.href.value) {
|
||
|
el[i].removeAttribute('target');
|
||
|
el[i].removeAttribute('rel');
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
},
|
||
|
|
||
|
/*
|
||
|
* this function adds one or several classes on an a element.
|
||
|
* if el parameter is not an a, it will look for a children of el.
|
||
|
* if no a children are found, it will look for the a parent.
|
||
|
*/
|
||
|
addClassToAnchors: function (el, buttonClass) {
|
||
|
var classes = buttonClass.split(' '),
|
||
|
i,
|
||
|
j;
|
||
|
if (el.nodeName.toLowerCase() === 'a') {
|
||
|
for (j = 0; j < classes.length; j += 1) {
|
||
|
el.classList.add(classes[j]);
|
||
|
}
|
||
|
} else {
|
||
|
var aChildren = el.getElementsByTagName('a');
|
||
|
if (aChildren.length === 0) {
|
||
|
var parentAnchor = Util.getClosestTag(el, 'a');
|
||
|
el = parentAnchor ? [parentAnchor] : [];
|
||
|
} else {
|
||
|
el = aChildren;
|
||
|
}
|
||
|
for (i = 0; i < el.length; i += 1) {
|
||
|
for (j = 0; j < classes.length; j += 1) {
|
||
|
el[i].classList.add(classes[j]);
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
},
|
||
|
|
||
|
isListItem: function (node) {
|
||
|
if (!node) {
|
||
|
return false;
|
||
|
}
|
||
|
if (node.nodeName.toLowerCase() === 'li') {
|
||
|
return true;
|
||
|
}
|
||
|
|
||
|
var parentNode = node.parentNode,
|
||
|
tagName = parentNode.nodeName.toLowerCase();
|
||
|
while (tagName === 'li' || (!Util.isBlockContainer(parentNode) && tagName !== 'div')) {
|
||
|
if (tagName === 'li') {
|
||
|
return true;
|
||
|
}
|
||
|
parentNode = parentNode.parentNode;
|
||
|
if (parentNode) {
|
||
|
tagName = parentNode.nodeName.toLowerCase();
|
||
|
} else {
|
||
|
return false;
|
||
|
}
|
||
|
}
|
||
|
return false;
|
||
|
},
|
||
|
|
||
|
cleanListDOM: function (ownerDocument, element) {
|
||
|
if (element.nodeName.toLowerCase() !== 'li') {
|
||
|
return;
|
||
|
}
|
||
|
|
||
|
var list = element.parentElement;
|
||
|
|
||
|
if (list.parentElement.nodeName.toLowerCase() === 'p') { // yes we need to clean up
|
||
|
Util.unwrap(list.parentElement, ownerDocument);
|
||
|
|
||
|
// move cursor at the end of the text inside the list
|
||
|
// for some unknown reason, the cursor is moved to end of the "visual" line
|
||
|
MediumEditor.selection.moveCursor(ownerDocument, element.firstChild, element.firstChild.textContent.length);
|
||
|
}
|
||
|
},
|
||
|
|
||
|
/* splitDOMTree
|
||
|
*
|
||
|
* Given a root element some descendant element, split the root element
|
||
|
* into its own element containing the descendant element and all elements
|
||
|
* on the left or right side of the descendant ('right' is default)
|
||
|
*
|
||
|
* example:
|
||
|
*
|
||
|
* <div>
|
||
|
* / | \
|
||
|
* <span> <span> <span>
|
||
|
* / \ / \ / \
|
||
|
* 1 2 3 4 5 6
|
||
|
*
|
||
|
* If I wanted to split this tree given the <div> as the root and "4" as the leaf
|
||
|
* the result would be (the prime ' marks indicates nodes that are created as clones):
|
||
|
*
|
||
|
* SPLITTING OFF 'RIGHT' TREE SPLITTING OFF 'LEFT' TREE
|
||
|
*
|
||
|
* <div> <div>' <div>' <div>
|
||
|
* / \ / \ / \ |
|
||
|
* <span> <span> <span>' <span> <span> <span> <span>
|
||
|
* / \ | | / \ /\ /\ /\
|
||
|
* 1 2 3 4 5 6 1 2 3 4 5 6
|
||
|
*
|
||
|
* The above example represents splitting off the 'right' or 'left' part of a tree, where
|
||
|
* the <div>' would be returned as an element not appended to the DOM, and the <div>
|
||
|
* would remain in place where it was
|
||
|
*
|
||
|
*/
|
||
|
splitOffDOMTree: function (rootNode, leafNode, splitLeft) {
|
||
|
var splitOnNode = leafNode,
|
||
|
createdNode = null,
|
||
|
splitRight = !splitLeft;
|
||
|
|
||
|
// loop until we hit the root
|
||
|
while (splitOnNode !== rootNode) {
|
||
|
var currParent = splitOnNode.parentNode,
|
||
|
newParent = currParent.cloneNode(false),
|
||
|
targetNode = (splitRight ? splitOnNode : currParent.firstChild),
|
||
|
appendLast;
|
||
|
|
||
|
// Create a new parent element which is a clone of the current parent
|
||
|
if (createdNode) {
|
||
|
if (splitRight) {
|
||
|
// If we're splitting right, add previous created element before siblings
|
||
|
newParent.appendChild(createdNode);
|
||
|
} else {
|
||
|
// If we're splitting left, add previous created element last
|
||
|
appendLast = createdNode;
|
||
|
}
|
||
|
}
|
||
|
createdNode = newParent;
|
||
|
|
||
|
while (targetNode) {
|
||
|
var sibling = targetNode.nextSibling;
|
||
|
// Special handling for the 'splitNode'
|
||
|
if (targetNode === splitOnNode) {
|
||
|
if (!targetNode.hasChildNodes()) {
|
||
|
targetNode.parentNode.removeChild(targetNode);
|
||
|
} else {
|
||
|
// For the node we're splitting on, if it has children, we need to clone it
|
||
|
// and not just move it
|
||
|
targetNode = targetNode.cloneNode(false);
|
||
|
}
|
||
|
// If the resulting split node has content, add it
|
||
|
if (targetNode.textContent) {
|
||
|
createdNode.appendChild(targetNode);
|
||
|
}
|
||
|
|
||
|
targetNode = (splitRight ? sibling : null);
|
||
|
} else {
|
||
|
// For general case, just remove the element and only
|
||
|
// add it to the split tree if it contains something
|
||
|
targetNode.parentNode.removeChild(targetNode);
|
||
|
if (targetNode.hasChildNodes() || targetNode.textContent) {
|
||
|
createdNode.appendChild(targetNode);
|
||
|
}
|
||
|
|
||
|
targetNode = sibling;
|
||
|
}
|
||
|
}
|
||
|
|
||
|
// If we had an element we wanted to append at the end, do that now
|
||
|
if (appendLast) {
|
||
|
createdNode.appendChild(appendLast);
|
||
|
}
|
||
|
|
||
|
splitOnNode = currParent;
|
||
|
}
|
||
|
|
||
|
return createdNode;
|
||
|
},
|
||
|
|
||
|
moveTextRangeIntoElement: function (startNode, endNode, newElement) {
|
||
|
if (!startNode || !endNode) {
|
||
|
return false;
|
||
|
}
|
||
|
|
||
|
var rootNode = Util.findCommonRoot(startNode, endNode);
|
||
|
if (!rootNode) {
|
||
|
return false;
|
||
|
}
|
||
|
|
||
|
if (endNode === startNode) {
|
||
|
var temp = startNode.parentNode,
|
||
|
sibling = startNode.nextSibling;
|
||
|
temp.removeChild(startNode);
|
||
|
newElement.appendChild(startNode);
|
||
|
if (sibling) {
|
||
|
temp.insertBefore(newElement, sibling);
|
||
|
} else {
|
||
|
temp.appendChild(newElement);
|
||
|
}
|
||
|
return newElement.hasChildNodes();
|
||
|
}
|
||
|
|
||
|
// create rootChildren array which includes all the children
|
||
|
// we care about
|
||
|
var rootChildren = [],
|
||
|
firstChild,
|
||
|
lastChild,
|
||
|
nextNode;
|
||
|
for (var i = 0; i < rootNode.childNodes.length; i++) {
|
||
|
nextNode = rootNode.childNodes[i];
|
||
|
if (!firstChild) {
|
||
|
if (Util.isDescendant(nextNode, startNode, true)) {
|
||
|
firstChild = nextNode;
|
||
|
}
|
||
|
} else {
|
||
|
if (Util.isDescendant(nextNode, endNode, true)) {
|
||
|
lastChild = nextNode;
|
||
|
break;
|
||
|
} else {
|
||
|
rootChildren.push(nextNode);
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
|
||
|
var afterLast = lastChild.nextSibling,
|
||
|
fragment = rootNode.ownerDocument.createDocumentFragment();
|
||
|
|
||
|
// build up fragment on startNode side of tree
|
||
|
if (firstChild === startNode) {
|
||
|
firstChild.parentNode.removeChild(firstChild);
|
||
|
fragment.appendChild(firstChild);
|
||
|
} else {
|
||
|
fragment.appendChild(Util.splitOffDOMTree(firstChild, startNode));
|
||
|
}
|
||
|
|
||
|
// add any elements between firstChild & lastChild
|
||
|
rootChildren.forEach(function (element) {
|
||
|
element.parentNode.removeChild(element);
|
||
|
fragment.appendChild(element);
|
||
|
});
|
||
|
|
||
|
// build up fragment on endNode side of the tree
|
||
|
if (lastChild === endNode) {
|
||
|
lastChild.parentNode.removeChild(lastChild);
|
||
|
fragment.appendChild(lastChild);
|
||
|
} else {
|
||
|
fragment.appendChild(Util.splitOffDOMTree(lastChild, endNode, true));
|
||
|
}
|
||
|
|
||
|
// Add fragment into passed in element
|
||
|
newElement.appendChild(fragment);
|
||
|
|
||
|
if (lastChild.parentNode === rootNode) {
|
||
|
// If last child is in the root, insert newElement in front of it
|
||
|
rootNode.insertBefore(newElement, lastChild);
|
||
|
} else if (afterLast) {
|
||
|
// If last child was removed, but it had a sibling, insert in front of it
|
||
|
rootNode.insertBefore(newElement, afterLast);
|
||
|
} else {
|
||
|
// lastChild was removed and was the last actual element just append
|
||
|
rootNode.appendChild(newElement);
|
||
|
}
|
||
|
|
||
|
return newElement.hasChildNodes();
|
||
|
},
|
||
|
|
||
|
/* based on http://stackoverflow.com/a/6183069 */
|
||
|
depthOfNode: function (inNode) {
|
||
|
var theDepth = 0,
|
||
|
node = inNode;
|
||
|
while (node.parentNode !== null) {
|
||
|
node = node.parentNode;
|
||
|
theDepth++;
|
||
|
}
|
||
|
return theDepth;
|
||
|
},
|
||
|
|
||
|
findCommonRoot: function (inNode1, inNode2) {
|
||
|
var depth1 = Util.depthOfNode(inNode1),
|
||
|
depth2 = Util.depthOfNode(inNode2),
|
||
|
node1 = inNode1,
|
||
|
node2 = inNode2;
|
||
|
|
||
|
while (depth1 !== depth2) {
|
||
|
if (depth1 > depth2) {
|
||
|
node1 = node1.parentNode;
|
||
|
depth1 -= 1;
|
||
|
} else {
|
||
|
node2 = node2.parentNode;
|
||
|
depth2 -= 1;
|
||
|
}
|
||
|
}
|
||
|
|
||
|
while (node1 !== node2) {
|
||
|
node1 = node1.parentNode;
|
||
|
node2 = node2.parentNode;
|
||
|
}
|
||
|
|
||
|
return node1;
|
||
|
},
|
||
|
/* END - based on http://stackoverflow.com/a/6183069 */
|
||
|
|
||
|
isElementAtBeginningOfBlock: function (node) {
|
||
|
var textVal,
|
||
|
sibling;
|
||
|
while (!Util.isBlockContainer(node) && !Util.isMediumEditorElement(node)) {
|
||
|
sibling = node;
|
||
|
while (sibling = sibling.previousSibling) {
|
||
|
textVal = sibling.nodeType === 3 ? sibling.nodeValue : sibling.textContent;
|
||
|
if (textVal.length > 0) {
|
||
|
return false;
|
||
|
}
|
||
|
}
|
||
|
node = node.parentNode;
|
||
|
}
|
||
|
return true;
|
||
|
},
|
||
|
|
||
|
isMediumEditorElement: function (element) {
|
||
|
return element && element.getAttribute && !!element.getAttribute('data-medium-editor-element');
|
||
|
},
|
||
|
|
||
|
getContainerEditorElement: function (element) {
|
||
|
return Util.traverseUp(element, function (node) {
|
||
|
return Util.isMediumEditorElement(node);
|
||
|
});
|
||
|
},
|
||
|
|
||
|
isBlockContainer: function (element) {
|
||
|
return element && element.nodeType !== 3 && Util.blockContainerElementNames.indexOf(element.nodeName.toLowerCase()) !== -1;
|
||
|
},
|
||
|
|
||
|
/* Finds the closest ancestor which is a block container element
|
||
|
* If element is within editor element but not within any other block element,
|
||
|
* the editor element is returned
|
||
|
*/
|
||
|
getClosestBlockContainer: function (node) {
|
||
|
return Util.traverseUp(node, function (node) {
|
||
|
return Util.isBlockContainer(node) || Util.isMediumEditorElement(node);
|
||
|
});
|
||
|
},
|
||
|
|
||
|
/* Finds highest level ancestor element which is a block container element
|
||
|
* If element is within editor element but not within any other block element,
|
||
|
* the editor element is returned
|
||
|
*/
|
||
|
getTopBlockContainer: function (element) {
|
||
|
var topBlock = Util.isBlockContainer(element) ? element : false;
|
||
|
Util.traverseUp(element, function (el) {
|
||
|
if (Util.isBlockContainer(el)) {
|
||
|
topBlock = el;
|
||
|
}
|
||
|
if (!topBlock && Util.isMediumEditorElement(el)) {
|
||
|
topBlock = el;
|
||
|
return true;
|
||
|
}
|
||
|
return false;
|
||
|
});
|
||
|
return topBlock;
|
||
|
},
|
||
|
|
||
|
getFirstSelectableLeafNode: function (element) {
|
||
|
while (element && element.firstChild) {
|
||
|
element = element.firstChild;
|
||
|
}
|
||
|
|
||
|
// We don't want to set the selection to an element that can't have children, this messes up Gecko.
|
||
|
element = Util.traverseUp(element, function (el) {
|
||
|
return Util.emptyElementNames.indexOf(el.nodeName.toLowerCase()) === -1;
|
||
|
});
|
||
|
// Selecting at the beginning of a table doesn't work in PhantomJS.
|
||
|
if (element.nodeName.toLowerCase() === 'table') {
|
||
|
var firstCell = element.querySelector('th, td');
|
||
|
if (firstCell) {
|
||
|
element = firstCell;
|
||
|
}
|
||
|
}
|
||
|
return element;
|
||
|
},
|
||
|
|
||
|
// TODO: remove getFirstTextNode AND _getFirstTextNode when jumping in 6.0.0 (no code references)
|
||
|
getFirstTextNode: function (element) {
|
||
|
Util.warn('getFirstTextNode is deprecated and will be removed in version 6.0.0');
|
||
|
return Util._getFirstTextNode(element);
|
||
|
},
|
||
|
|
||
|
_getFirstTextNode: function (element) {
|
||
|
if (element.nodeType === 3) {
|
||
|
return element;
|
||
|
}
|
||
|
|
||
|
for (var i = 0; i < element.childNodes.length; i++) {
|
||
|
var textNode = Util._getFirstTextNode(element.childNodes[i]);
|
||
|
if (textNode !== null) {
|
||
|
return textNode;
|
||
|
}
|
||
|
}
|
||
|
return null;
|
||
|
},
|
||
|
|
||
|
ensureUrlHasProtocol: function (url) {
|
||
|
if (url.indexOf('://') === -1) {
|
||
|
return 'http://' + url;
|
||
|
}
|
||
|
return url;
|
||
|
},
|
||
|
|
||
|
warn: function () {
|
||
|
if (window.console !== undefined && typeof window.console.warn === 'function') {
|
||
|
window.console.warn.apply(window.console, arguments);
|
||
|
}
|
||
|
},
|
||
|
|
||
|
deprecated: function (oldName, newName, version) {
|
||
|
// simple deprecation warning mechanism.
|
||
|
var m = oldName + ' is deprecated, please use ' + newName + ' instead.';
|
||
|
if (version) {
|
||
|
m += ' Will be removed in ' + version;
|
||
|
}
|
||
|
Util.warn(m);
|
||
|
},
|
||
|
|
||
|
deprecatedMethod: function (oldName, newName, args, version) {
|
||
|
// run the replacement and warn when someone calls a deprecated method
|
||
|
Util.deprecated(oldName, newName, version);
|
||
|
if (typeof this[newName] === 'function') {
|
||
|
this[newName].apply(this, args);
|
||
|
}
|
||
|
},
|
||
|
|
||
|
cleanupAttrs: function (el, attrs) {
|
||
|
attrs.forEach(function (attr) {
|
||
|
el.removeAttribute(attr);
|
||
|
});
|
||
|
},
|
||
|
|
||
|
cleanupTags: function (el, tags) {
|
||
|
if (tags.indexOf(el.nodeName.toLowerCase()) !== -1) {
|
||
|
el.parentNode.removeChild(el);
|
||
|
}
|
||
|
},
|
||
|
|
||
|
unwrapTags: function (el, tags) {
|
||
|
if (tags.indexOf(el.nodeName.toLowerCase()) !== -1) {
|
||
|
MediumEditor.util.unwrap(el, document);
|
||
|
}
|
||
|
},
|
||
|
|
||
|
// get the closest parent
|
||
|
getClosestTag: function (el, tag) {
|
||
|
return Util.traverseUp(el, function (element) {
|
||
|
return element.nodeName.toLowerCase() === tag.toLowerCase();
|
||
|
});
|
||
|
},
|
||
|
|
||
|
unwrap: function (el, doc) {
|
||
|
var fragment = doc.createDocumentFragment(),
|
||
|
nodes = Array.prototype.slice.call(el.childNodes);
|
||
|
|
||
|
// cast nodeList to array since appending child
|
||
|
// to a different node will alter length of el.childNodes
|
||
|
for (var i = 0; i < nodes.length; i++) {
|
||
|
fragment.appendChild(nodes[i]);
|
||
|
}
|
||
|
|
||
|
if (fragment.childNodes.length) {
|
||
|
el.parentNode.replaceChild(fragment, el);
|
||
|
} else {
|
||
|
el.parentNode.removeChild(el);
|
||
|
}
|
||
|
},
|
||
|
|
||
|
guid: function () {
|
||
|
function _s4() {
|
||
|
return Math
|
||
|
.floor((1 + Math.random()) * 0x10000)
|
||
|
.toString(16)
|
||
|
.substring(1);
|
||
|
}
|
||
|
|
||
|
return _s4() + _s4() + '-' + _s4() + '-' + _s4() + '-' + _s4() + '-' + _s4() + _s4() + _s4();
|
||
|
}
|
||
|
};
|
||
|
|
||
|
MediumEditor.util = Util;
|
||
|
}(window));
|
||
|
|
||
|
(function () {
|
||
|
'use strict';
|
||
|
|
||
|
var Extension = function (options) {
|
||
|
MediumEditor.util.extend(this, options);
|
||
|
};
|
||
|
|
||
|
Extension.extend = function (protoProps) {
|
||
|
// magic extender thinger. mostly borrowed from backbone/goog.inherits
|
||
|
// place this function on some thing you want extend-able.
|
||
|
//
|
||
|
// example:
|
||
|
//
|
||
|
// function Thing(args){
|
||
|
// this.options = args;
|
||
|
// }
|
||
|
//
|
||
|
// Thing.prototype = { foo: "bar" };
|
||
|
// Thing.extend = extenderify;
|
||
|
//
|
||
|
// var ThingTwo = Thing.extend({ foo: "baz" });
|
||
|
//
|
||
|
// var thingOne = new Thing(); // foo === "bar"
|
||
|
// var thingTwo = new ThingTwo(); // foo === "baz"
|
||
|
//
|
||
|
// which seems like some simply shallow copy nonsense
|
||
|
// at first, but a lot more is going on there.
|
||
|
//
|
||
|
// passing a `constructor` to the extend props
|
||
|
// will cause the instance to instantiate through that
|
||
|
// instead of the parent's constructor.
|
||
|
|
||
|
var parent = this,
|
||
|
child;
|
||
|
|
||
|
// The constructor function for the new subclass is either defined by you
|
||
|
// (the "constructor" property in your `extend` definition), or defaulted
|
||
|
// by us to simply call the parent's constructor.
|
||
|
|
||
|
if (protoProps && protoProps.hasOwnProperty('constructor')) {
|
||
|
child = protoProps.constructor;
|
||
|
} else {
|
||
|
child = function () {
|
||
|
return parent.apply(this, arguments);
|
||
|
};
|
||
|
}
|
||
|
|
||
|
// das statics (.extend comes over, so your subclass can have subclasses too)
|
||
|
MediumEditor.util.extend(child, parent);
|
||
|
|
||
|
// Set the prototype chain to inherit from `parent`, without calling
|
||
|
// `parent`'s constructor function.
|
||
|
var Surrogate = function () {
|
||
|
this.constructor = child;
|
||
|
};
|
||
|
Surrogate.prototype = parent.prototype;
|
||
|
child.prototype = new Surrogate();
|
||
|
|
||
|
if (protoProps) {
|
||
|
MediumEditor.util.extend(child.prototype, protoProps);
|
||
|
}
|
||
|
|
||
|
// todo: $super?
|
||
|
|
||
|
return child;
|
||
|
};
|
||
|
|
||
|
Extension.prototype = {
|
||
|
/* init: [function]
|
||
|
*
|
||
|
* 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
|
||
|
*/
|
||
|
init: function () {},
|
||
|
|
||
|
/* base: [MediumEditor instance]
|
||
|
*
|
||
|
* If not overriden, this will be set to the current instance
|
||
|
* of MediumEditor, before the init method is called
|
||
|
*/
|
||
|
base: undefined,
|
||
|
|
||
|
/* name: [string]
|
||
|
*
|
||
|
* 'name' of the extension, used for retrieving the extension.
|
||
|
* If not set, MediumEditor will set this to be the key
|
||
|
* used when passing the extension into MediumEditor via the
|
||
|
* 'extensions' option
|
||
|
*/
|
||
|
name: undefined,
|
||
|
|
||
|
/* checkState: [function (node)]
|
||
|
*
|
||
|
* If implemented, this function will be called one or more times
|
||
|
* 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 on the 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
|
||
|
*/
|
||
|
checkState: undefined,
|
||
|
|
||
|
/* destroy: [function ()]
|
||
|
*
|
||
|
* This method should remove any created html, custom event handlers
|
||
|
* or any other cleanup tasks that should be performed.
|
||
|
* If implemented, this function will be called when MediumEditor's
|
||
|
* destroy method has been called.
|
||
|
*/
|
||
|
destroy: undefined,
|
||
|
|
||
|
/* As alternatives to checkState, these functions provide a more structured
|
||
|
* path to updating the state of an extension (usually a button) whenever
|
||
|
* the state of the editor & toolbar are updated.
|
||
|
*/
|
||
|
|
||
|
/* queryCommandState: [function ()]
|
||
|
*
|
||
|
* If implemented, this function will be called once on each extension
|
||
|
* when the state of the editor/toolbar is being updated.
|
||
|
*
|
||
|
* If this function returns a non-null value, the extension will
|
||
|
* be ignored as the code climbs the dom tree.
|
||
|
*
|
||
|
* If this function returns true, and the setActive() function is defined
|
||
|
* setActive() will be called
|
||
|
*/
|
||
|
queryCommandState: undefined,
|
||
|
|
||
|
/* isActive: [function ()]
|
||
|
*
|
||
|
* If implemented, this function 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 queryCommandState() or isAlreadyApplied() functions
|
||
|
* are implemented, and when called, return true.
|
||
|
*/
|
||
|
isActive: undefined,
|
||
|
|
||
|
/* isAlreadyApplied: [function (node)]
|
||
|
*
|
||
|
* If implemented, this function 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 function will NOT be called if checkState() has
|
||
|
* been implemented. This function will NOT be called if
|
||
|
* queryCommandState() is implemented and returns a non-null
|
||
|
* value when called
|
||
|
*/
|
||
|
isAlreadyApplied: undefined,
|
||
|
|
||
|
/* setActive: [function ()]
|
||
|
*
|
||
|
* If implemented, this function is called when MediumEditor knows
|
||
|
* that this extension is currently enabled. Currently, this
|
||
|
* function is called when updating the editor & toolbar, and
|
||
|
* only if queryCommandState() or isAlreadyApplied(node) return
|
||
|
* true when called
|
||
|
*/
|
||
|
setActive: undefined,
|
||
|
|
||
|
/* setInactive: [function ()]
|
||
|
*
|
||
|
* If implemented, this function is called when MediumEditor knows
|
||
|
* that this extension is currently disabled. 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()
|
||
|
*/
|
||
|
setInactive: undefined,
|
||
|
|
||
|
/* getInteractionElements: [function ()]
|
||
|
*
|
||
|
* If the extension renders any elements that the user can interact with,
|
||
|
* this method should be implemented and return the root element or an array
|
||
|
* containing all of the root elements. MediumEditor will call this function
|
||
|
* during interaction to see if the user clicked on something outside of the editor.
|
||
|
* The elements are used to check if the target element of a click or
|
||
|
* other user event is a descendant of any extension elements.
|
||
|
* This way, the editor can also count user interaction within editor elements as
|
||
|
* interactions with the editor, and thus not trigger 'blur'
|
||
|
*/
|
||
|
getInteractionElements: undefined,
|
||
|
|
||
|
/************************ 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
|
||
|
*********************************************************/
|
||
|
|
||
|
/* window: [Window]
|
||
|
*
|
||
|
* If not overriden, this will be set to the window object
|
||
|
* to be used by MediumEditor and its extensions. This is
|
||
|
* passed via the 'contentWindow' option to MediumEditor
|
||
|
* and is the global 'window' object by default
|
||
|
*/
|
||
|
'window': undefined,
|
||
|
|
||
|
/* document: [Document]
|
||
|
*
|
||
|
* If not overriden, this will be set to the document object
|
||
|
* to be used by MediumEditor and its extensions. This is
|
||
|
* passed via the 'ownerDocument' optin to MediumEditor
|
||
|
* and is the global 'document' object by default
|
||
|
*/
|
||
|
'document': undefined,
|
||
|
|
||
|
/* getEditorElements: [function ()]
|
||
|
*
|
||
|
* Helper function which returns an array containing
|
||
|
* all the contenteditable elements for this instance
|
||
|
* of MediumEditor
|
||
|
*/
|
||
|
getEditorElements: function () {
|
||
|
return this.base.elements;
|
||
|
},
|
||
|
|
||
|
/* getEditorId: [function ()]
|
||
|
*
|
||
|
* Helper function which returns a unique identifier
|
||
|
* for this instance of MediumEditor
|
||
|
*/
|
||
|
getEditorId: function () {
|
||
|
return this.base.id;
|
||
|
},
|
||
|
|
||
|
/* getEditorOptions: [function (option)]
|
||
|
*
|
||
|
* Helper function which returns the value of an option
|
||
|
* used to initialize this instance of MediumEditor
|
||
|
*/
|
||
|
getEditorOption: function (option) {
|
||
|
return this.base.options[option];
|
||
|
}
|
||
|
};
|
||
|
|
||
|
/* List of method names to add to the prototype of Extension
|
||
|
* Each of these methods will be defined as helpers that
|
||
|
* just call directly into the MediumEditor instance.
|
||
|
*
|
||
|
* example for 'on' method:
|
||
|
* Extension.prototype.on = function () {
|
||
|
* return this.base.on.apply(this.base, arguments);
|
||
|
* }
|
||
|
*/
|
||
|
[
|
||
|
// general helpers
|
||
|
'execAction',
|
||
|
|
||
|
// event handling
|
||
|
'on',
|
||
|
'off',
|
||
|
'subscribe',
|
||
|
'trigger'
|
||
|
|
||
|
].forEach(function (helper) {
|
||
|
Extension.prototype[helper] = function () {
|
||
|
return this.base[helper].apply(this.base, arguments);
|
||
|
};
|
||
|
});
|
||
|
|
||
|
MediumEditor.Extension = Extension;
|
||
|
})();
|
||
|
|
||
|
(function () {
|
||
|
'use strict';
|
||
|
|
||
|
function filterOnlyParentElements(node) {
|
||
|
if (MediumEditor.util.isBlockContainer(node)) {
|
||
|
return NodeFilter.FILTER_ACCEPT;
|
||
|
} else {
|
||
|
return NodeFilter.FILTER_SKIP;
|
||
|
}
|
||
|
}
|
||
|
|
||
|
var Selection = {
|
||
|
findMatchingSelectionParent: function (testElementFunction, contentWindow) {
|
||
|
var selection = contentWindow.getSelection(),
|
||
|
range,
|
||
|
current;
|
||
|
|
||
|
if (selection.rangeCount === 0) {
|
||
|
return false;
|
||
|
}
|
||
|
|
||
|
range = selection.getRangeAt(0);
|
||
|
current = range.commonAncestorContainer;
|
||
|
|
||
|
return MediumEditor.util.traverseUp(current, testElementFunction);
|
||
|
},
|
||
|
|
||
|
getSelectionElement: function (contentWindow) {
|
||
|
return this.findMatchingSelectionParent(function (el) {
|
||
|
return MediumEditor.util.isMediumEditorElement(el);
|
||
|
}, contentWindow);
|
||
|
},
|
||
|
|
||
|
// http://stackoverflow.com/questions/17678843/cant-restore-selection-after-html-modify-even-if-its-the-same-html
|
||
|
// Tim Down
|
||
|
exportSelection: function (root, doc) {
|
||
|
if (!root) {
|
||
|
return null;
|
||
|
}
|
||
|
|
||
|
var selectionState = null,
|
||
|
selection = doc.getSelection();
|
||
|
|
||
|
if (selection.rangeCount > 0) {
|
||
|
var range = selection.getRangeAt(0),
|
||
|
preSelectionRange = range.cloneRange(),
|
||
|
start;
|
||
|
|
||
|
preSelectionRange.selectNodeContents(root);
|
||
|
preSelectionRange.setEnd(range.startContainer, range.startOffset);
|
||
|
start = preSelectionRange.toString().length;
|
||
|
|
||
|
selectionState = {
|
||
|
start: start,
|
||
|
end: start + range.toString().length
|
||
|
};
|
||
|
|
||
|
// Check to see if the selection starts with any images
|
||
|
// if so we need to make sure the the beginning of the selection is
|
||
|
// set correctly when importing selection
|
||
|
if (this.doesRangeStartWithImages(range, doc)) {
|
||
|
selectionState.startsWithImage = true;
|
||
|
}
|
||
|
|
||
|
// Check to see if the selection has any trailing images
|
||
|
// if so, this this means we need to look for them when we import selection
|
||
|
var trailingImageCount = this.getTrailingImageCount(root, selectionState, range.endContainer, range.endOffset);
|
||
|
if (trailingImageCount) {
|
||
|
selectionState.trailingImageCount = trailingImageCount;
|
||
|
}
|
||
|
|
||
|
// If start = 0 there may still be an empty paragraph before it, but we don't care.
|
||
|
if (start !== 0) {
|
||
|
var emptyBlocksIndex = this.getIndexRelativeToAdjacentEmptyBlocks(doc, root, range.startContainer, range.startOffset);
|
||
|
if (emptyBlocksIndex !== -1) {
|
||
|
selectionState.emptyBlocksIndex = emptyBlocksIndex;
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
|
||
|
return selectionState;
|
||
|
},
|
||
|
|
||
|
// http://stackoverflow.com/questions/17678843/cant-restore-selection-after-html-modify-even-if-its-the-same-html
|
||
|
// Tim Down
|
||
|
//
|
||
|
// {object} selectionState - the selection to import
|
||
|
// {DOMElement} root - the root element the selection is being restored inside of
|
||
|
// {Document} doc - the document to use for managing selection
|
||
|
// {boolean} [favorLaterSelectionAnchor] - defaults to false. If true, import the cursor immediately
|
||
|
// subsequent to an anchor tag if it would otherwise be placed right at the trailing edge inside the
|
||
|
// anchor. This cursor positioning, even though visually equivalent to the user, can affect behavior
|
||
|
// in MS IE.
|
||
|
importSelection: function (selectionState, root, doc, favorLaterSelectionAnchor) {
|
||
|
if (!selectionState || !root) {
|
||
|
return;
|
||
|
}
|
||
|
|
||
|
var range = doc.createRange();
|
||
|
range.setStart(root, 0);
|
||
|
range.collapse(true);
|
||
|
|
||
|
var node = root,
|
||
|
nodeStack = [],
|
||
|
charIndex = 0,
|
||
|
foundStart = false,
|
||
|
foundEnd = false,
|
||
|
trailingImageCount = 0,
|
||
|
stop = false,
|
||
|
nextCharIndex,
|
||
|
allowRangeToStartAtEndOfNode = false,
|
||
|
lastTextNode = null;
|
||
|
|
||
|
// When importing selection, the start of the selection may lie at the end of an element
|
||
|
// or at the beginning of an element. Since visually there is no difference between these 2
|
||
|
// we will try to move the selection to the beginning of an element since this is generally
|
||
|
// what users will expect and it's a more predictable behavior.
|
||
|
//
|
||
|
// However, there are some specific cases when we don't want to do this:
|
||
|
// 1) We're attempting to move the cursor outside of the end of an anchor [favorLaterSelectionAnchor = true]
|
||
|
// 2) The selection starts with an image, which is special since an image doesn't have any 'content'
|
||
|
// as far as selection and ranges are concerned
|
||
|
// 3) The selection starts after a specified number of empty block elements (selectionState.emptyBlocksIndex)
|
||
|
//
|
||
|
// For these cases, we want the selection to start at a very specific location, so we should NOT
|
||
|
// automatically move the cursor to the beginning of the first actual chunk of text
|
||
|
if (favorLaterSelectionAnchor || selectionState.startsWithImage || typeof selectionState.emptyBlocksIndex !== 'undefined') {
|
||
|
allowRangeToStartAtEndOfNode = true;
|
||
|
}
|
||
|
|
||
|
while (!stop && node) {
|
||
|
// Only iterate over elements and text nodes
|
||
|
if (node.nodeType > 3) {
|
||
|
node = nodeStack.pop();
|
||
|
continue;
|
||
|
}
|
||
|
|
||
|
// If we hit a text node, we need to add the amount of characters to the overall count
|
||
|
if (node.nodeType === 3 && !foundEnd) {
|
||
|
nextCharIndex = charIndex + node.length;
|
||
|
// Check if we're at or beyond the start of the selection we're importing
|
||
|
if (!foundStart && selectionState.start >= charIndex && selectionState.start <= nextCharIndex) {
|
||
|
// NOTE: We only want to allow a selection to start at the END of an element if
|
||
|
// allowRangeToStartAtEndOfNode is true
|
||
|
if (allowRangeToStartAtEndOfNode || selectionState.start < nextCharIndex) {
|
||
|
range.setStart(node, selectionState.start - charIndex);
|
||
|
foundStart = true;
|
||
|
}
|
||
|
// We're at the end of a text node where the selection could start but we shouldn't
|
||
|
// make the selection start here because allowRangeToStartAtEndOfNode is false.
|
||
|
// However, we should keep a reference to this node in case there aren't any more
|
||
|
// text nodes after this, so that we have somewhere to import the selection to
|
||
|
else {
|
||
|
lastTextNode = node;
|
||
|
}
|
||
|
}
|
||
|
// We've found the start of the selection, check if we're at or beyond the end of the selection we're importing
|
||
|
if (foundStart && selectionState.end >= charIndex && selectionState.end <= nextCharIndex) {
|
||
|
if (!selectionState.trailingImageCount) {
|
||
|
range.setEnd(node, selectionState.end - charIndex);
|
||
|
stop = true;
|
||
|
} else {
|
||
|
foundEnd = true;
|
||
|
}
|
||
|
}
|
||
|
charIndex = nextCharIndex;
|
||
|
} else {
|
||
|
if (selectionState.trailingImageCount && foundEnd) {
|
||
|
if (node.nodeName.toLowerCase() === 'img') {
|
||
|
trailingImageCount++;
|
||
|
}
|
||
|
if (trailingImageCount === selectionState.trailingImageCount) {
|
||
|
// Find which index the image is in its parent's children
|
||
|
var endIndex = 0;
|
||
|
while (node.parentNode.childNodes[endIndex] !== node) {
|
||
|
endIndex++;
|
||
|
}
|
||
|
range.setEnd(node.parentNode, endIndex + 1);
|
||
|
stop = true;
|
||
|
}
|
||
|
}
|
||
|
|
||
|
if (!stop && node.nodeType === 1) {
|
||
|
// this is an element
|
||
|
// add all its children to the stack
|
||
|
var i = node.childNodes.length - 1;
|
||
|
while (i >= 0) {
|
||
|
nodeStack.push(node.childNodes[i]);
|
||
|
i -= 1;
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
|
||
|
if (!stop) {
|
||
|
node = nodeStack.pop();
|
||
|
}
|
||
|
}
|
||
|
|
||
|
// If we've gone through the entire text but didn't find the beginning of a text node
|
||
|
// to make the selection start at, we should fall back to starting the selection
|
||
|
// at the END of the last text node we found
|
||
|
if (!foundStart && lastTextNode) {
|
||
|
range.setStart(lastTextNode, lastTextNode.length);
|
||
|
range.setEnd(lastTextNode, lastTextNode.length);
|
||
|
}
|
||
|
|
||
|
if (typeof selectionState.emptyBlocksIndex !== 'undefined') {
|
||
|
range = this.importSelectionMoveCursorPastBlocks(doc, root, selectionState.emptyBlocksIndex, range);
|
||
|
}
|
||
|
|
||
|
// If the selection is right at the ending edge of a link, put it outside the anchor tag instead of inside.
|
||
|
if (favorLaterSelectionAnchor) {
|
||
|
range = this.importSelectionMoveCursorPastAnchor(selectionState, range);
|
||
|
}
|
||
|
|
||
|
this.selectRange(doc, range);
|
||
|
},
|
||
|
|
||
|
// Utility method called from importSelection only
|
||
|
importSelectionMoveCursorPastAnchor: function (selectionState, range) {
|
||
|
var nodeInsideAnchorTagFunction = function (node) {
|
||
|
return node.nodeName.toLowerCase() === 'a';
|
||
|
};
|
||
|
if (selectionState.start === selectionState.end &&
|
||
|
range.startContainer.nodeType === 3 &&
|
||
|
range.startOffset === range.startContainer.nodeValue.length &&
|
||
|
MediumEditor.util.traverseUp(range.startContainer, nodeInsideAnchorTagFunction)) {
|
||
|
var prevNode = range.startContainer,
|
||
|
currentNode = range.startContainer.parentNode;
|
||
|
while (currentNode !== null && currentNode.nodeName.toLowerCase() !== 'a') {
|
||
|
if (currentNode.childNodes[currentNode.childNodes.length - 1] !== prevNode) {
|
||
|
currentNode = null;
|
||
|
} else {
|
||
|
prevNode = currentNode;
|
||
|
currentNode = currentNode.parentNode;
|
||
|
}
|
||
|
}
|
||
|
if (currentNode !== null && currentNode.nodeName.toLowerCase() === 'a') {
|
||
|
var currentNodeIndex = null;
|
||
|
for (var i = 0; currentNodeIndex === null && i < currentNode.parentNode.childNodes.length; i++) {
|
||
|
if (currentNode.parentNode.childNodes[i] === currentNode) {
|
||
|
currentNodeIndex = i;
|
||
|
}
|
||
|
}
|
||
|
range.setStart(currentNode.parentNode, currentNodeIndex + 1);
|
||
|
range.collapse(true);
|
||
|
}
|
||
|
}
|
||
|
return range;
|
||
|
},
|
||
|
|
||
|
// Uses the emptyBlocksIndex calculated by getIndexRelativeToAdjacentEmptyBlocks
|
||
|
// to move the cursor back to the start of the correct paragraph
|
||
|
importSelectionMoveCursorPastBlocks: function (doc, root, index, range) {
|
||
|
var treeWalker = doc.createTreeWalker(root, NodeFilter.SHOW_ELEMENT, filterOnlyParentElements, false),
|
||
|
startContainer = range.startContainer,
|
||
|
startBlock,
|
||
|
targetNode,
|
||
|
currIndex = 0;
|
||
|
index = index || 1; // If index is 0, we still want to move to the next block
|
||
|
|
||
|
// Chrome counts newlines and spaces that separate block elements as actual elements.
|
||
|
// If the selection is inside one of these text nodes, and it has a previous sibling
|
||
|
// which is a block element, we want the treewalker to start at the previous sibling
|
||
|
// and NOT at the parent of the textnode
|
||
|
if (startContainer.nodeType === 3 && MediumEditor.util.isBlockContainer(startContainer.previousSibling)) {
|
||
|
startBlock = startContainer.previousSibling;
|
||
|
} else {
|
||
|
startBlock = MediumEditor.util.getClosestBlockContainer(startContainer);
|
||
|
}
|
||
|
|
||
|
// Skip over empty blocks until we hit the block we want the selection to be in
|
||
|
while (treeWalker.nextNode()) {
|
||
|
if (!targetNode) {
|
||
|
// Loop through all blocks until we hit the starting block element
|
||
|
if (startBlock === treeWalker.currentNode) {
|
||
|
targetNode = treeWalker.currentNode;
|
||
|
}
|
||
|
} else {
|
||
|
targetNode = treeWalker.currentNode;
|
||
|
currIndex++;
|
||
|
// We hit the target index, bail
|
||
|
if (currIndex === index) {
|
||
|
break;
|
||
|
}
|
||
|
// If we find a non-empty block, ignore the emptyBlocksIndex and just put selection here
|
||
|
if (targetNode.textContent.length > 0) {
|
||
|
break;
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
|
||
|
if (!targetNode) {
|
||
|
targetNode = startBlock;
|
||
|
}
|
||
|
|
||
|
// We're selecting a high-level block node, so make sure the cursor gets moved into the deepest
|
||
|
// element at the beginning of the block
|
||
|
range.setStart(MediumEditor.util.getFirstSelectableLeafNode(targetNode), 0);
|
||
|
|
||
|
return range;
|
||
|
},
|
||
|
|
||
|
// Returns -1 unless the cursor is at the beginning of a paragraph/block
|
||
|
// If the paragraph/block is preceeded by empty paragraphs/block (with no text)
|
||
|
// it will return the number of empty paragraphs before the cursor.
|
||
|
// Otherwise, it will return 0, which indicates the cursor is at the beginning
|
||
|
// of a paragraph/block, and not at the end of the paragraph/block before it
|
||
|
getIndexRelativeToAdjacentEmptyBlocks: function (doc, root, cursorContainer, cursorOffset) {
|
||
|
// If there is text in front of the cursor, that means there isn't only empty blocks before it
|
||
|
if (cursorContainer.textContent.length > 0 && cursorOffset > 0) {
|
||
|
return -1;
|
||
|
}
|
||
|
|
||
|
// Check if the block that contains the cursor has any other text in front of the cursor
|
||
|
var node = cursorContainer;
|
||
|
if (node.nodeType !== 3) {
|
||
|
node = cursorContainer.childNodes[cursorOffset];
|
||
|
}
|
||
|
if (node) {
|
||
|
// The element isn't at the beginning of a block, so it has content before it
|
||
|
if (!MediumEditor.util.isElementAtBeginningOfBlock(node)) {
|
||
|
return -1;
|
||
|
}
|
||
|
|
||
|
var previousSibling = MediumEditor.util.findPreviousSibling(node);
|
||
|
// If there is no previous sibling, this is the first text element in the editor
|
||
|
if (!previousSibling) {
|
||
|
return -1;
|
||
|
}
|
||
|
// If the previous sibling has text, then there are no empty blocks before this
|
||
|
else if (previousSibling.nodeValue) {
|
||
|
return -1;
|
||
|
}
|
||
|
}
|
||
|
|
||
|
// Walk over block elements, counting number of empty blocks between last piece of text
|
||
|
// and the block the cursor is in
|
||
|
var closestBlock = MediumEditor.util.getClosestBlockContainer(cursorContainer),
|
||
|
treeWalker = doc.createTreeWalker(root, NodeFilter.SHOW_ELEMENT, filterOnlyParentElements, false),
|
||
|
emptyBlocksCount = 0;
|
||
|
while (treeWalker.nextNode()) {
|
||
|
var blockIsEmpty = treeWalker.currentNode.textContent === '';
|
||
|
if (blockIsEmpty || emptyBlocksCount > 0) {
|
||
|
emptyBlocksCount += 1;
|
||
|
}
|
||
|
if (treeWalker.currentNode === closestBlock) {
|
||
|
return emptyBlocksCount;
|
||
|
}
|
||
|
if (!blockIsEmpty) {
|
||
|
emptyBlocksCount = 0;
|
||
|
}
|
||
|
}
|
||
|
|
||
|
return emptyBlocksCount;
|
||
|
},
|
||
|
|
||
|
// Returns true if the selection range begins with an image tag
|
||
|
// Returns false if the range starts with any non empty text nodes
|
||
|
doesRangeStartWithImages: function (range, doc) {
|
||
|
if (range.startOffset !== 0 || range.startContainer.nodeType !== 1) {
|
||
|
return false;
|
||
|
}
|
||
|
|
||
|
if (range.startContainer.nodeName.toLowerCase() === 'img') {
|
||
|
return true;
|
||
|
}
|
||
|
|
||
|
var img = range.startContainer.querySelector('img');
|
||
|
if (!img) {
|
||
|
return false;
|
||
|
}
|
||
|
|
||
|
var treeWalker = doc.createTreeWalker(range.startContainer, NodeFilter.SHOW_ALL, null, false);
|
||
|
while (treeWalker.nextNode()) {
|
||
|
var next = treeWalker.currentNode;
|
||
|
// If we hit the image, then there isn't any text before the image so
|
||
|
// the image is at the beginning of the range
|
||
|
if (next === img) {
|
||
|
break;
|
||
|
}
|
||
|
// If we haven't hit the iamge, but found text that contains content
|
||
|
// then the range doesn't start with an image
|
||
|
if (next.nodeValue) {
|
||
|
return false;
|
||
|
}
|
||
|
}
|
||
|
|
||
|
return true;
|
||
|
},
|
||
|
|
||
|
getTrailingImageCount: function (root, selectionState, endContainer, endOffset) {
|
||
|
// If the endOffset of a range is 0, the endContainer doesn't contain images
|
||
|
// If the endContainer is a text node, there are no trailing images
|
||
|
if (endOffset === 0 || endContainer.nodeType !== 1) {
|
||
|
return 0;
|
||
|
}
|
||
|
|
||
|
// If the endContainer isn't an image, and doesn't have an image descendants
|
||
|
// there are no trailing images
|
||
|
if (endContainer.nodeName.toLowerCase() !== 'img' && !endContainer.querySelector('img')) {
|
||
|
return 0;
|
||
|
}
|
||
|
|
||
|
var lastNode = endContainer.childNodes[endOffset - 1];
|
||
|
while (lastNode.hasChildNodes()) {
|
||
|
lastNode = lastNode.lastChild;
|
||
|
}
|
||
|
|
||
|
var node = root,
|
||
|
nodeStack = [],
|
||
|
charIndex = 0,
|
||
|
foundStart = false,
|
||
|
foundEnd = false,
|
||
|
stop = false,
|
||
|
nextCharIndex,
|
||
|
trailingImages = 0;
|
||
|
|
||
|
while (!stop && node) {
|
||
|
// Only iterate over elements and text nodes
|
||
|
if (node.nodeType > 3) {
|
||
|
node = nodeStack.pop();
|
||
|
continue;
|
||
|
}
|
||
|
|
||
|
if (node.nodeType === 3 && !foundEnd) {
|
||
|
trailingImages = 0;
|
||
|
nextCharIndex = charIndex + node.length;
|
||
|
if (!foundStart && selectionState.start >= charIndex && selectionState.start <= nextCharIndex) {
|
||
|
foundStart = true;
|
||
|
}
|
||
|
if (foundStart && selectionState.end >= charIndex && selectionState.end <= nextCharIndex) {
|
||
|
foundEnd = true;
|
||
|
}
|
||
|
charIndex = nextCharIndex;
|
||
|
} else {
|
||
|
if (node.nodeName.toLowerCase() === 'img') {
|
||
|
trailingImages++;
|
||
|
}
|
||
|
|
||
|
if (node === lastNode) {
|
||
|
stop = true;
|
||
|
} else if (node.nodeType === 1) {
|
||
|
// this is an element
|
||
|
// add all its children to the stack
|
||
|
var i = node.childNodes.length - 1;
|
||
|
while (i >= 0) {
|
||
|
nodeStack.push(node.childNodes[i]);
|
||
|
i -= 1;
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
|
||
|
if (!stop) {
|
||
|
node = nodeStack.pop();
|
||
|
}
|
||
|
}
|
||
|
|
||
|
return trailingImages;
|
||
|
},
|
||
|
|
||
|
// determine if the current selection contains any 'content'
|
||
|
// content being any non-white space text or an image
|
||
|
selectionContainsContent: function (doc) {
|
||
|
var sel = doc.getSelection();
|
||
|
|
||
|
// collapsed selection or selection withour range doesn't contain content
|
||
|
if (!sel || sel.isCollapsed || !sel.rangeCount) {
|
||
|
return false;
|
||
|
}
|
||
|
|
||
|
// if toString() contains any text, the selection contains some content
|
||
|
if (sel.toString().trim() !== '') {
|
||
|
return true;
|
||
|
}
|
||
|
|
||
|
// if selection contains only image(s), it will return empty for toString()
|
||
|
// so check for an image manually
|
||
|
var selectionNode = this.getSelectedParentElement(sel.getRangeAt(0));
|
||
|
if (selectionNode) {
|
||
|
if (selectionNode.nodeName.toLowerCase() === 'img' ||
|
||
|
(selectionNode.nodeType === 1 && selectionNode.querySelector('img'))) {
|
||
|
return true;
|
||
|
}
|
||
|
}
|
||
|
|
||
|
return false;
|
||
|
},
|
||
|
|
||
|
selectionInContentEditableFalse: function (contentWindow) {
|
||
|
// determine if the current selection is exclusively inside
|
||
|
// a contenteditable="false", though treat the case of an
|
||
|
// explicit contenteditable="true" inside a "false" as false.
|
||
|
var sawtrue,
|
||
|
sawfalse = this.findMatchingSelectionParent(function (el) {
|
||
|
var ce = el && el.getAttribute('contenteditable');
|
||
|
if (ce === 'true') {
|
||
|
sawtrue = true;
|
||
|
}
|
||
|
return el.nodeName !== '#text' && ce === 'false';
|
||
|
}, contentWindow);
|
||
|
|
||
|
return !sawtrue && sawfalse;
|
||
|
},
|
||
|
|
||
|
// http://stackoverflow.com/questions/4176923/html-of-selected-text
|
||
|
// by Tim Down
|
||
|
getSelectionHtml: function getSelectionHtml(doc) {
|
||
|
var i,
|
||
|
html = '',
|
||
|
sel = doc.getSelection(),
|
||
|
len,
|
||
|
container;
|
||
|
if (sel.rangeCount) {
|
||
|
container = doc.createElement('div');
|
||
|
for (i = 0, len = sel.rangeCount; i < len; i += 1) {
|
||
|
container.appendChild(sel.getRangeAt(i).cloneContents());
|
||
|
}
|
||
|
html = container.innerHTML;
|
||
|
}
|
||
|
return html;
|
||
|
},
|
||
|
|
||
|
/**
|
||
|
* Find the caret position within an element irrespective of any inline tags it may contain.
|
||
|
*
|
||
|
* @param {DOMElement} An element containing the cursor to find offsets relative to.
|
||
|
* @param {Range} A Range representing cursor position. Will window.getSelection if none is passed.
|
||
|
* @return {Object} 'left' and 'right' attributes contain offsets from begining and end of Element
|
||
|
*/
|
||
|
getCaretOffsets: function getCaretOffsets(element, range) {
|
||
|
var preCaretRange, postCaretRange;
|
||
|
|
||
|
if (!range) {
|
||
|
range = window.getSelection().getRangeAt(0);
|
||
|
}
|
||
|
|
||
|
preCaretRange = range.cloneRange();
|
||
|
postCaretRange = range.cloneRange();
|
||
|
|
||
|
preCaretRange.selectNodeContents(element);
|
||
|
preCaretRange.setEnd(range.endContainer, range.endOffset);
|
||
|
|
||
|
postCaretRange.selectNodeContents(element);
|
||
|
postCaretRange.setStart(range.endContainer, range.endOffset);
|
||
|
|
||
|
return {
|
||
|
left: preCaretRange.toString().length,
|
||
|
right: postCaretRange.toString().length
|
||
|
};
|
||
|
},
|
||
|
|
||
|
// http://stackoverflow.com/questions/15867542/range-object-get-selection-parent-node-chrome-vs-firefox
|
||
|
rangeSelectsSingleNode: function (range) {
|
||
|
var startNode = range.startContainer;
|
||
|
return startNode === range.endContainer &&
|
||
|
startNode.hasChildNodes() &&
|
||
|
range.endOffset === range.startOffset + 1;
|
||
|
},
|
||
|
|
||
|
getSelectedParentElement: function (range) {
|
||
|
if (!range) {
|
||
|
return null;
|
||
|
}
|
||
|
|
||
|
// Selection encompasses a single element
|
||
|
if (this.rangeSelectsSingleNode(range) && range.startContainer.childNodes[range.startOffset].nodeType !== 3) {
|
||
|
return range.startContainer.childNodes[range.startOffset];
|
||
|
}
|
||
|
|
||
|
// Selection range starts inside a text node, so get its parent
|
||
|
if (range.startContainer.nodeType === 3) {
|
||
|
return range.startContainer.parentNode;
|
||
|
}
|
||
|
|
||
|
// Selection starts inside an element
|
||
|
return range.startContainer;
|
||
|
},
|
||
|
|
||
|
getSelectedElements: function (doc) {
|
||
|
var selection = doc.getSelection(),
|
||
|
range,
|
||
|
toRet,
|
||
|
currNode;
|
||
|
|
||
|
if (!selection.rangeCount || selection.isCollapsed || !selection.getRangeAt(0).commonAncestorContainer) {
|
||
|
return [];
|
||
|
}
|
||
|
|
||
|
range = selection.getRangeAt(0);
|
||
|
|
||
|
if (range.commonAncestorContainer.nodeType === 3) {
|
||
|
toRet = [];
|
||
|
currNode = range.commonAncestorContainer;
|
||
|
while (currNode.parentNode && currNode.parentNode.childNodes.length === 1) {
|
||
|
toRet.push(currNode.parentNode);
|
||
|
currNode = currNode.parentNode;
|
||
|
}
|
||
|
|
||
|
return toRet;
|
||
|
}
|
||
|
|
||
|
return [].filter.call(range.commonAncestorContainer.getElementsByTagName('*'), function (el) {
|
||
|
return (typeof selection.containsNode === 'function') ? selection.containsNode(el, true) : true;
|
||
|
});
|
||
|
},
|
||
|
|
||
|
selectNode: function (node, doc) {
|
||
|
var range = doc.createRange();
|
||
|
range.selectNodeContents(node);
|
||
|
this.selectRange(doc, range);
|
||
|
},
|
||
|
|
||
|
select: function (doc, startNode, startOffset, endNode, endOffset) {
|
||
|
var range = doc.createRange();
|
||
|
range.setStart(startNode, startOffset);
|
||
|
if (endNode) {
|
||
|
range.setEnd(endNode, endOffset);
|
||
|
} else {
|
||
|
range.collapse(true);
|
||
|
}
|
||
|
this.selectRange(doc, range);
|
||
|
return range;
|
||
|
},
|
||
|
|
||
|
/**
|
||
|
* Clear the current highlighted selection and set the caret to the start or the end of that prior selection, defaults to end.
|
||
|
*
|
||
|
* @param {DomDocument} doc Current document
|
||
|
* @param {boolean} moveCursorToStart A boolean representing whether or not to set the caret to the beginning of the prior selection.
|
||
|
*/
|
||
|
clearSelection: function (doc, moveCursorToStart) {
|
||
|
if (moveCursorToStart) {
|
||
|
doc.getSelection().collapseToStart();
|
||
|
} else {
|
||
|
doc.getSelection().collapseToEnd();
|
||
|
}
|
||
|
},
|
||
|
|
||
|
/**
|
||
|
* Move cursor to the given node with the given offset.
|
||
|
*
|
||
|
* @param {DomDocument} doc Current document
|
||
|
* @param {DomElement} node Element where to jump
|
||
|
* @param {integer} offset Where in the element should we jump, 0 by default
|
||
|
*/
|
||
|
moveCursor: function (doc, node, offset) {
|
||
|
this.select(doc, node, offset);
|
||
|
},
|
||
|
|
||
|
getSelectionRange: function (ownerDocument) {
|
||
|
var selection = ownerDocument.getSelection();
|
||
|
if (selection.rangeCount === 0) {
|
||
|
return null;
|
||
|
}
|
||
|
return selection.getRangeAt(0);
|
||
|
},
|
||
|
|
||
|
selectRange: function (ownerDocument, range) {
|
||
|
var selection = ownerDocument.getSelection();
|
||
|
|
||
|
selection.removeAllRanges();
|
||
|
selection.addRange(range);
|
||
|
},
|
||
|
|
||
|
// http://stackoverflow.com/questions/1197401/how-can-i-get-the-element-the-caret-is-in-with-javascript-when-using-contentedi
|
||
|
// by You
|
||
|
getSelectionStart: function (ownerDocument) {
|
||
|
var node = ownerDocument.getSelection().anchorNode,
|
||
|
startNode = (node && node.nodeType === 3 ? node.parentNode : node);
|
||
|
|
||
|
return startNode;
|
||
|
}
|
||
|
};
|
||
|
|
||
|
MediumEditor.selection = Selection;
|
||
|
}());
|
||
|
|
||
|
(function () {
|
||
|
'use strict';
|
||
|
|
||
|
function isElementDescendantOfExtension(extensions, element) {
|
||
|
if (!extensions) {
|
||
|
return false;
|
||
|
}
|
||
|
|
||
|
return extensions.some(function (extension) {
|
||
|
if (typeof extension.getInteractionElements !== 'function') {
|
||
|
return false;
|
||
|
}
|
||
|
|
||
|
var extensionElements = extension.getInteractionElements();
|
||
|
if (!extensionElements) {
|
||
|
return false;
|
||
|
}
|
||
|
|
||
|
if (!Array.isArray(extensionElements)) {
|
||
|
extensionElements = [extensionElements];
|
||
|
}
|
||
|
return extensionElements.some(function (el) {
|
||
|
return MediumEditor.util.isDescendant(el, element, true);
|
||
|
});
|
||
|
});
|
||
|
}
|
||
|
|
||
|
var Events = function (instance) {
|
||
|
this.base = instance;
|
||
|
this.options = this.base.options;
|
||
|
this.events = [];
|
||
|
this.disabledEvents = {};
|
||
|
this.customEvents = {};
|
||
|
this.listeners = {};
|
||
|
};
|
||
|
|
||
|
Events.prototype = {
|
||
|
InputEventOnContenteditableSupported: !MediumEditor.util.isIE && !MediumEditor.util.isEdge,
|
||
|
|
||
|
// Helpers for event handling
|
||
|
|
||
|
attachDOMEvent: function (targets, event, listener, useCapture) {
|
||
|
var win = this.base.options.contentWindow,
|
||
|
doc = this.base.options.ownerDocument;
|
||
|
|
||
|
targets = MediumEditor.util.isElement(targets) || [win, doc].indexOf(targets) > -1 ? [targets] : targets;
|
||
|
|
||
|
Array.prototype.forEach.call(targets, function (target) {
|
||
|
target.addEventListener(event, listener, useCapture);
|
||
|
this.events.push([target, event, listener, useCapture]);
|
||
|
}.bind(this));
|
||
|
},
|
||
|
|
||
|
detachDOMEvent: function (targets, event, listener, useCapture) {
|
||
|
var index, e,
|
||
|
win = this.base.options.contentWindow,
|
||
|
doc = this.base.options.ownerDocument;
|
||
|
|
||
|
if (targets) {
|
||
|
targets = MediumEditor.util.isElement(targets) || [win, doc].indexOf(targets) > -1 ? [targets] : targets;
|
||
|
|
||
|
Array.prototype.forEach.call(targets, function (target) {
|
||
|
index = this.indexOfListener(target, event, listener, useCapture);
|
||
|
if (index !== -1) {
|
||
|
e = this.events.splice(index, 1)[0];
|
||
|
e[0].removeEventListener(e[1], e[2], e[3]);
|
||
|
}
|
||
|
}.bind(this));
|
||
|
}
|
||
|
},
|
||
|
|
||
|
indexOfListener: function (target, event, listener, useCapture) {
|
||
|
var i, n, item;
|
||
|
for (i = 0, n = this.events.length; i < n; i = i + 1) {
|
||
|
item = this.events[i];
|
||
|
if (item[0] === target && item[1] === event && item[2] === listener && item[3] === useCapture) {
|
||
|
return i;
|
||
|
}
|
||
|
}
|
||
|
return -1;
|
||
|
},
|
||
|
|
||
|
detachAllDOMEvents: function () {
|
||
|
var e = this.events.pop();
|
||
|
while (e) {
|
||
|
e[0].removeEventListener(e[1], e[2], e[3]);
|
||
|
e = this.events.pop();
|
||
|
}
|
||
|
},
|
||
|
|
||
|
detachAllEventsFromElement: function (element) {
|
||
|
var filtered = this.events.filter(function (e) {
|
||
|
return e && e[0].getAttribute && e[0].getAttribute('medium-editor-index') === element.getAttribute('medium-editor-index');
|
||
|
});
|
||
|
|
||
|
for (var i = 0, len = filtered.length; i < len; i++) {
|
||
|
var e = filtered[i];
|
||
|
this.detachDOMEvent(e[0], e[1], e[2], e[3]);
|
||
|
}
|
||
|
},
|
||
|
|
||
|
// Attach all existing handlers to a new element
|
||
|
attachAllEventsToElement: function (element) {
|
||
|
if (this.listeners['editableInput']) {
|
||
|
this.contentCache[element.getAttribute('medium-editor-index')] = element.innerHTML;
|
||
|
}
|
||
|
|
||
|
if (this.eventsCache) {
|
||
|
this.eventsCache.forEach(function (e) {
|
||
|
this.attachDOMEvent(element, e['name'], e['handler'].bind(this));
|
||
|
}, this);
|
||
|
}
|
||
|
},
|
||
|
|
||
|
enableCustomEvent: function (event) {
|
||
|
if (this.disabledEvents[event] !== undefined) {
|
||
|
delete this.disabledEvents[event];
|
||
|
}
|
||
|
},
|
||
|
|
||
|
disableCustomEvent: function (event) {
|
||
|
this.disabledEvents[event] = true;
|
||
|
},
|
||
|
|
||
|
// custom events
|
||
|
attachCustomEvent: function (event, listener) {
|
||
|
this.setupListener(event);
|
||
|
if (!this.customEvents[event]) {
|
||
|
this.customEvents[event] = [];
|
||
|
}
|
||
|
this.customEvents[event].push(listener);
|
||
|
},
|
||
|
|
||
|
detachCustomEvent: function (event, listener) {
|
||
|
var index = this.indexOfCustomListener(event, listener);
|
||
|
if (index !== -1) {
|
||
|
this.customEvents[event].splice(index, 1);
|
||
|
// TODO: If array is empty, should detach internal listeners via destroyListener()
|
||
|
}
|
||
|
},
|
||
|
|
||
|
indexOfCustomListener: function (event, listener) {
|
||
|
if (!this.customEvents[event] || !this.customEvents[event].length) {
|
||
|
return -1;
|
||
|
}
|
||
|
|
||
|
return this.customEvents[event].indexOf(listener);
|
||
|
},
|
||
|
|
||
|
detachAllCustomEvents: function () {
|
||
|
this.customEvents = {};
|
||
|
// TODO: Should detach internal listeners here via destroyListener()
|
||
|
},
|
||
|
|
||
|
triggerCustomEvent: function (name, data, editable) {
|
||
|
if (this.customEvents[name] && !this.disabledEvents[name]) {
|
||
|
this.customEvents[name].forEach(function (listener) {
|
||
|
listener(data, editable);
|
||
|
});
|
||
|
}
|
||
|
},
|
||
|
|
||
|
// Cleaning up
|
||
|
|
||
|
destroy: function () {
|
||
|
this.detachAllDOMEvents();
|
||
|
this.detachAllCustomEvents();
|
||
|
this.detachExecCommand();
|
||
|
|
||
|
if (this.base.elements) {
|
||
|
this.base.elements.forEach(function (element) {
|
||
|
element.removeAttribute('data-medium-focused');
|
||
|
});
|
||
|
}
|
||
|
},
|
||
|
|
||
|
// Listening to calls to document.execCommand
|
||
|
|
||
|
// Attach a listener to be notified when document.execCommand is called
|
||
|
attachToExecCommand: function () {
|
||
|
if (this.execCommandListener) {
|
||
|
return;
|
||
|
}
|
||
|
|
||
|
// Store an instance of the listener so:
|
||
|
// 1) We only attach to execCommand once
|
||
|
// 2) We can remove the listener later
|
||
|
this.execCommandListener = function (execInfo) {
|
||
|
this.handleDocumentExecCommand(execInfo);
|
||
|
}.bind(this);
|
||
|
|
||
|
// Ensure that execCommand has been wrapped correctly
|
||
|
this.wrapExecCommand();
|
||
|
|
||
|
// Add listener to list of execCommand listeners
|
||
|
this.options.ownerDocument.execCommand.listeners.push(this.execCommandListener);
|
||
|
},
|
||
|
|
||
|
// Remove our listener for calls to document.execCommand
|
||
|
detachExecCommand: function () {
|
||
|
var doc = this.options.ownerDocument;
|
||
|
if (!this.execCommandListener || !doc.execCommand.listeners) {
|
||
|
return;
|
||
|
}
|
||
|
|
||
|
// Find the index of this listener in the array of listeners so it can be removed
|
||
|
var index = doc.execCommand.listeners.indexOf(this.execCommandListener);
|
||
|
if (index !== -1) {
|
||
|
doc.execCommand.listeners.splice(index, 1);
|
||
|
}
|
||
|
|
||
|
// If the list of listeners is now empty, put execCommand back to its original state
|
||
|
if (!doc.execCommand.listeners.length) {
|
||
|
this.unwrapExecCommand();
|
||
|
}
|
||
|
},
|
||
|
|
||
|
// Wrap document.execCommand in a custom method so we can listen to calls to it
|
||
|
wrapExecCommand: function () {
|
||
|
var doc = this.options.ownerDocument;
|
||
|
|
||
|
// Ensure all instance of MediumEditor only wrap execCommand once
|
||
|
if (doc.execCommand.listeners) {
|
||
|
return;
|
||
|
}
|
||
|
|
||
|
// Helper method to call all listeners to execCommand
|
||
|
var callListeners = function (args, result) {
|
||
|
if (doc.execCommand.listeners) {
|
||
|
doc.execCommand.listeners.forEach(function (listener) {
|
||
|
listener({
|
||
|
command: args[0],
|
||
|
value: args[2],
|
||
|
args: args,
|
||
|
result: result
|
||
|
});
|
||
|
});
|
||
|
}
|
||
|
},
|
||
|
|
||
|
// Create a wrapper method for execCommand which will:
|
||
|
// 1) Call document.execCommand with the correct arguments
|
||
|
// 2) Loop through any listeners and notify them that execCommand was called
|
||
|
// passing extra info on the call
|
||
|
// 3) Return the result
|
||
|
wrapper = function () {
|
||
|
var result = doc.execCommand.orig.apply(this, arguments);
|
||
|
|
||
|
if (!doc.execCommand.listeners) {
|
||
|
return result;
|
||
|
}
|
||
|
|
||
|
var args = Array.prototype.slice.call(arguments);
|
||
|
callListeners(args, result);
|
||
|
|
||
|
return result;
|
||
|
};
|
||
|
|
||
|
// Store a reference to the original execCommand
|
||
|
wrapper.orig = doc.execCommand;
|
||
|
|
||
|
// Attach an array for storing listeners
|
||
|
wrapper.listeners = [];
|
||
|
|
||
|
// Helper for notifying listeners
|
||
|
wrapper.callListeners = callListeners;
|
||
|
|
||
|
// Overwrite execCommand
|
||
|
doc.execCommand = wrapper;
|
||
|
},
|
||
|
|
||
|
// Revert document.execCommand back to its original self
|
||
|
unwrapExecCommand: function () {
|
||
|
var doc = this.options.ownerDocument;
|
||
|
if (!doc.execCommand.orig) {
|
||
|
return;
|
||
|
}
|
||
|
|
||
|
// Use the reference to the original execCommand to revert back
|
||
|
doc.execCommand = doc.execCommand.orig;
|
||
|
},
|
||
|
|
||
|
// Listening to browser events to emit events medium-editor cares about
|
||
|
setupListener: function (name) {
|
||
|
if (this.listeners[name]) {
|
||
|
return;
|
||
|
}
|
||
|
|
||
|
switch (name) {
|
||
|
case 'externalInteraction':
|
||
|
// Detecting when user has interacted with elements outside of MediumEditor
|
||
|
this.attachDOMEvent(this.options.ownerDocument.body, 'mousedown', this.handleBodyMousedown.bind(this), true);
|
||
|
this.attachDOMEvent(this.options.ownerDocument.body, 'click', this.handleBodyClick.bind(this), true);
|
||
|
this.attachDOMEvent(this.options.ownerDocument.body, 'focus', this.handleBodyFocus.bind(this), true);
|
||
|
break;
|
||
|
case 'blur':
|
||
|
// Detecting when focus is lost
|
||
|
this.setupListener('externalInteraction');
|
||
|
break;
|
||
|
case 'focus':
|
||
|
// Detecting when focus moves into some part of MediumEditor
|
||
|
this.setupListener('externalInteraction');
|
||
|
break;
|
||
|
case 'editableInput':
|
||
|
// setup cache for knowing when the content has changed
|
||
|
this.contentCache = {};
|
||
|
this.base.elements.forEach(function (element) {
|
||
|
this.contentCache[element.getAttribute('medium-editor-index')] = element.innerHTML;
|
||
|
}, this);
|
||
|
|
||
|
// Attach to the 'oninput' event, handled correctly by most browsers
|
||
|
if (this.InputEventOnContenteditableSupported) {
|
||
|
this.attachToEachElement('input', this.handleInput);
|
||
|
}
|
||
|
|
||
|
// For browsers which don't support the input event on contenteditable (IE)
|
||
|
// we'll attach to 'selectionchange' on the document and 'keypress' on the editables
|
||
|
if (!this.InputEventOnContenteditableSupported) {
|
||
|
this.setupListener('editableKeypress');
|
||
|
this.keypressUpdateInput = true;
|
||
|
this.attachDOMEvent(document, 'selectionchange', this.handleDocumentSelectionChange.bind(this));
|
||
|
// Listen to calls to execCommand
|
||
|
this.attachToExecCommand();
|
||
|
}
|
||
|
break;
|
||
|
case 'editableClick':
|
||
|
// Detecting click in the contenteditables
|
||
|
this.attachToEachElement('click', this.handleClick);
|
||
|
break;
|
||
|
case 'editableBlur':
|
||
|
// Detecting blur in the contenteditables
|
||
|
this.attachToEachElement('blur', this.handleBlur);
|
||
|
break;
|
||
|
case 'editableKeypress':
|
||
|
// Detecting keypress in the contenteditables
|
||
|
this.attachToEachElement('keypress', this.handleKeypress);
|
||
|
break;
|
||
|
case 'editableKeyup':
|
||
|
// Detecting keyup in the contenteditables
|
||
|
this.attachToEachElement('keyup', this.handleKeyup);
|
||
|
break;
|
||
|
case 'editableKeydown':
|
||
|
// Detecting keydown on the contenteditables
|
||
|
this.attachToEachElement('keydown', this.handleKeydown);
|
||
|
break;
|
||
|
case 'editableKeydownSpace':
|
||
|
// Detecting keydown for SPACE on the contenteditables
|
||
|
this.setupListener('editableKeydown');
|
||
|
break;
|
||
|
case 'editableKeydownEnter':
|
||
|
// Detecting keydown for ENTER on the contenteditables
|
||
|
this.setupListener('editableKeydown');
|
||
|
break;
|
||
|
case 'editableKeydownTab':
|
||
|
// Detecting keydown for TAB on the contenteditable
|
||
|
this.setupListener('editableKeydown');
|
||
|
break;
|
||
|
case 'editableKeydownDelete':
|
||
|
// Detecting keydown for DELETE/BACKSPACE on the contenteditables
|
||
|
this.setupListener('editableKeydown');
|
||
|
break;
|
||
|
case 'editableMouseover':
|
||
|
// Detecting mouseover on the contenteditables
|
||
|
this.attachToEachElement('mouseover', this.handleMouseover);
|
||
|
break;
|
||
|
case 'editableDrag':
|
||
|
// Detecting dragover and dragleave on the contenteditables
|
||
|
this.attachToEachElement('dragover', this.handleDragging);
|
||
|
this.attachToEachElement('dragleave', this.handleDragging);
|
||
|
break;
|
||
|
case 'editableDrop':
|
||
|
// Detecting drop on the contenteditables
|
||
|
this.attachToEachElement('drop', this.handleDrop);
|
||
|
break;
|
||
|
// TODO: We need to have a custom 'paste' event separate from 'editablePaste'
|
||
|
// Need to think about the way to introduce this without breaking folks
|
||
|
case 'editablePaste':
|
||
|
// Detecting paste on the contenteditables
|
||
|
this.attachToEachElement('paste', this.handlePaste);
|
||
|
break;
|
||
|
}
|
||
|
this.listeners[name] = true;
|
||
|
},
|
||
|
|
||
|
attachToEachElement: function (name, handler) {
|
||
|
// build our internal cache to know which element got already what handler attached
|
||
|
if (!this.eventsCache) {
|
||
|
this.eventsCache = [];
|
||
|
}
|
||
|
|
||
|
this.base.elements.forEach(function (element) {
|
||
|
this.attachDOMEvent(element, name, handler.bind(this));
|
||
|
}, this);
|
||
|
|
||
|
this.eventsCache.push({ 'name': name, 'handler': handler });
|
||
|
},
|
||
|
|
||
|
cleanupElement: function (element) {
|
||
|
var index = element.getAttribute('medium-editor-index');
|
||
|
if (index) {
|
||
|
this.detachAllEventsFromElement(element);
|
||
|
if (this.contentCache) {
|
||
|
delete this.contentCache[index];
|
||
|
}
|
||
|
}
|
||
|
},
|
||
|
|
||
|
focusElement: function (element) {
|
||
|
element.focus();
|
||
|
this.updateFocus(element, { target: element, type: 'focus' });
|
||
|
},
|
||
|
|
||
|
updateFocus: function (target, eventObj) {
|
||
|
var hadFocus = this.base.getFocusedElement(),
|
||
|
toFocus;
|
||
|
|
||
|
// For clicks, we need to know if the mousedown that caused the click happened inside the existing focused element
|
||
|
// or one of the extension elements. If so, we don't want to focus another element
|
||
|
if (hadFocus &&
|
||
|
eventObj.type === 'click' &&
|
||
|
this.lastMousedownTarget &&
|
||
|
(MediumEditor.util.isDescendant(hadFocus, this.lastMousedownTarget, true) ||
|
||
|
isElementDescendantOfExtension(this.base.extensions, this.lastMousedownTarget))) {
|
||
|
toFocus = hadFocus;
|
||
|
}
|
||
|
|
||
|
if (!toFocus) {
|
||
|
this.base.elements.some(function (element) {
|
||
|
// If the target is part of an editor element, this is the element getting focus
|
||
|
if (!toFocus && (MediumEditor.util.isDescendant(element, target, true))) {
|
||
|
toFocus = element;
|
||
|
}
|
||
|
|
||
|
// bail if we found an element that's getting focus
|
||
|
return !!toFocus;
|
||
|
}, this);
|
||
|
}
|
||
|
|
||
|
// Check if the target is external (not part of the editor, toolbar, or any other extension)
|
||
|
var externalEvent = !MediumEditor.util.isDescendant(hadFocus, target, true) &&
|
||
|
!isElementDescendantOfExtension(this.base.extensions, target);
|
||
|
|
||
|
if (toFocus !== hadFocus) {
|
||
|
// If element has focus, and focus is going outside of editor
|
||
|
// Don't blur focused element if clicking on editor, toolbar, or anchorpreview
|
||
|
if (hadFocus && externalEvent) {
|
||
|
// Trigger blur on the editable that has lost focus
|
||
|
hadFocus.removeAttribute('data-medium-focused');
|
||
|
this.triggerCustomEvent('blur', eventObj, hadFocus);
|
||
|
}
|
||
|
|
||
|
// If focus is going into an editor element
|
||
|
if (toFocus) {
|
||
|
// Trigger focus on the editable that now has focus
|
||
|
toFocus.setAttribute('data-medium-focused', true);
|
||
|
this.triggerCustomEvent('focus', eventObj, toFocus);
|
||
|
}
|
||
|
}
|
||
|
|
||
|
if (externalEvent) {
|
||
|
this.triggerCustomEvent('externalInteraction', eventObj);
|
||
|
}
|
||
|
},
|
||
|
|
||
|
updateInput: function (target, eventObj) {
|
||
|
if (!this.contentCache) {
|
||
|
return;
|
||
|
}
|
||
|
// An event triggered which signifies that the user may have changed someting
|
||
|
// Look in our cache of input for the contenteditables to see if something changed
|
||
|
var index = target.getAttribute('medium-editor-index'),
|
||
|
html = target.innerHTML;
|
||
|
|
||
|
if (html !== this.contentCache[index]) {
|
||
|
// The content has changed since the last time we checked, fire the event
|
||
|
this.triggerCustomEvent('editableInput', eventObj, target);
|
||
|
}
|
||
|
this.contentCache[index] = html;
|
||
|
},
|
||
|
|
||
|
handleDocumentSelectionChange: function (event) {
|
||
|
// When selectionchange fires, target and current target are set
|
||
|
// to document, since this is where the event is handled
|
||
|
// However, currentTarget will have an 'activeElement' property
|
||
|
// which will point to whatever element has focus.
|
||
|
if (event.currentTarget && event.currentTarget.activeElement) {
|
||
|
var activeElement = event.currentTarget.activeElement,
|
||
|
currentTarget;
|
||
|
// We can look at the 'activeElement' to determine if the selectionchange has
|
||
|
// happened within a contenteditable owned by this instance of MediumEditor
|
||
|
this.base.elements.some(function (element) {
|
||
|
if (MediumEditor.util.isDescendant(element, activeElement, true)) {
|
||
|
currentTarget = element;
|
||
|
return true;
|
||
|
}
|
||
|
return false;
|
||
|
}, this);
|
||
|
|
||
|
// We know selectionchange fired within one of our contenteditables
|
||
|
if (currentTarget) {
|
||
|
this.updateInput(currentTarget, { target: activeElement, currentTarget: currentTarget });
|
||
|
}
|
||
|
}
|
||
|
},
|
||
|
|
||
|
handleDocumentExecCommand: function () {
|
||
|
// document.execCommand has been called
|
||
|
// If one of our contenteditables currently has focus, we should
|
||
|
// attempt to trigger the 'editableInput' event
|
||
|
var target = this.base.getFocusedElement();
|
||
|
if (target) {
|
||
|
this.updateInput(target, { target: target, currentTarget: target });
|
||
|
}
|
||
|
},
|
||
|
|
||
|
handleBodyClick: function (event) {
|
||
|
this.updateFocus(event.target, event);
|
||
|
},
|
||
|
|
||
|
handleBodyFocus: function (event) {
|
||
|
this.updateFocus(event.target, event);
|
||
|
},
|
||
|
|
||
|
handleBodyMousedown: function (event) {
|
||
|
this.lastMousedownTarget = event.target;
|
||
|
},
|
||
|
|
||
|
handleInput: function (event) {
|
||
|
this.updateInput(event.currentTarget, event);
|
||
|
},
|
||
|
|
||
|
handleClick: function (event) {
|
||
|
this.triggerCustomEvent('editableClick', event, event.currentTarget);
|
||
|
},
|
||
|
|
||
|
handleBlur: function (event) {
|
||
|
this.triggerCustomEvent('editableBlur', event, event.currentTarget);
|
||
|
},
|
||
|
|
||
|
handleKeypress: function (event) {
|
||
|
this.triggerCustomEvent('editableKeypress', event, event.currentTarget);
|
||
|
|
||
|
// If we're doing manual detection of the editableInput event we need
|
||
|
// to check for input changes during 'keypress'
|
||
|
if (this.keypressUpdateInput) {
|
||
|
var eventObj = { target: event.target, currentTarget: event.currentTarget };
|
||
|
|
||
|
// In IE, we need to let the rest of the event stack complete before we detect
|
||
|
// changes to input, so using setTimeout here
|
||
|
setTimeout(function () {
|
||
|
this.updateInput(eventObj.currentTarget, eventObj);
|
||
|
}.bind(this), 0);
|
||
|
}
|
||
|
},
|
||
|
|
||
|
handleKeyup: function (event) {
|
||
|
this.triggerCustomEvent('editableKeyup', event, event.currentTarget);
|
||
|
},
|
||
|
|
||
|
handleMouseover: function (event) {
|
||
|
this.triggerCustomEvent('editableMouseover', event, event.currentTarget);
|
||
|
},
|
||
|
|
||
|
handleDragging: function (event) {
|
||
|
this.triggerCustomEvent('editableDrag', event, event.currentTarget);
|
||
|
},
|
||
|
|
||
|
handleDrop: function (event) {
|
||
|
this.triggerCustomEvent('editableDrop', event, event.currentTarget);
|
||
|
},
|
||
|
|
||
|
handlePaste: function (event) {
|
||
|
this.triggerCustomEvent('editablePaste', event, event.currentTarget);
|
||
|
},
|
||
|
|
||
|
handleKeydown: function (event) {
|
||
|
|
||
|
this.triggerCustomEvent('editableKeydown', event, event.currentTarget);
|
||
|
|
||
|
if (MediumEditor.util.isKey(event, MediumEditor.util.keyCode.SPACE)) {
|
||
|
return this.triggerCustomEvent('editableKeydownSpace', event, event.currentTarget);
|
||
|
}
|
||
|
|
||
|
if (MediumEditor.util.isKey(event, MediumEditor.util.keyCode.ENTER) || (event.ctrlKey && MediumEditor.util.isKey(event, MediumEditor.util.keyCode.M))) {
|
||
|
return this.triggerCustomEvent('editableKeydownEnter', event, event.currentTarget);
|
||
|
}
|
||
|
|
||
|
if (MediumEditor.util.isKey(event, MediumEditor.util.keyCode.TAB)) {
|
||
|
return this.triggerCustomEvent('editableKeydownTab', event, event.currentTarget);
|
||
|
}
|
||
|
|
||
|
if (MediumEditor.util.isKey(event, [MediumEditor.util.keyCode.DELETE, MediumEditor.util.keyCode.BACKSPACE])) {
|
||
|
return this.triggerCustomEvent('editableKeydownDelete', event, event.currentTarget);
|
||
|
}
|
||
|
}
|
||
|
};
|
||
|
|
||
|
MediumEditor.Events = Events;
|
||
|
}());
|
||
|
|
||
|
(function () {
|
||
|
'use strict';
|
||
|
|
||
|
var Button = MediumEditor.Extension.extend({
|
||
|
|
||
|
/* Button Options */
|
||
|
|
||
|
/* action: [string]
|
||
|
* The action argument to pass to MediumEditor.execAction()
|
||
|
* when the button is clicked
|
||
|
*/
|
||
|
action: undefined,
|
||
|
|
||
|
/* aria: [string]
|
||
|
* The value to add as the aria-label attribute of the button
|
||
|
* element displayed in the toolbar.
|
||
|
* This is also used as the tooltip for the button
|
||
|
*/
|
||
|
aria: undefined,
|
||
|
|
||
|
/* tagNames: [Array]
|
||
|
* NOTE: This is not used if useQueryState is set to true.
|
||
|
*
|
||
|
* Array of element tag names that would indicate that this
|
||
|
* button has already been applied. If this action has already
|
||
|
* been applied, the button will be displayed as 'active' in the toolbar
|
||
|
*
|
||
|
* Example:
|
||
|
* For 'bold', if the text is ever within a <b> or <strong>
|
||
|
* tag that indicates the text is already bold. So the array
|
||
|
* of tagNames for bold would be: ['b', 'strong']
|
||
|
*/
|
||
|
tagNames: undefined,
|
||
|
|
||
|
/* style: [Object]
|
||
|
* NOTE: This is not used if useQueryState is set to true.
|
||
|
*
|
||
|
* A pair of css property & value(s) that indicate that this
|
||
|
* button has already been applied. If this action has already
|
||
|
* been applied, the button will be displayed as 'active' in the toolbar
|
||
|
* Properties of the object:
|
||
|
* prop [String]: name of the css property
|
||
|
* value [String]: value(s) of the css property
|
||
|
* multiple values can be separated by a '|'
|
||
|
*
|
||
|
* Example:
|
||
|
* For 'bold', if the text is ever within an element with a 'font-weight'
|
||
|
* style property set to '700' or 'bold', that indicates the text
|
||
|
* is already bold. So the style object for bold would be:
|
||
|
* { prop: 'font-weight', value: '700|bold' }
|
||
|
*/
|
||
|
style: undefined,
|
||
|
|
||
|
/* useQueryState: [boolean]
|
||
|
* Enables/disables whether this button should use the built-in
|
||
|
* document.queryCommandState() method to determine whether
|
||
|
* the action has already been applied. If the action has already
|
||
|
* been applied, the button will be displayed as 'active' in the toolbar
|
||
|
*
|
||
|
* Example:
|
||
|
* For 'bold', if this is set to true, the code will call:
|
||
|
* document.queryCommandState('bold') which will return true if the
|
||
|
* browser thinks the text is already bold, and false otherwise
|
||
|
*/
|
||
|
useQueryState: undefined,
|
||
|
|
||
|
/* contentDefault: [string]
|
||
|
* Default innerHTML to put inside the button
|
||
|
*/
|
||
|
contentDefault: undefined,
|
||
|
|
||
|
/* contentFA: [string]
|
||
|
* The innerHTML to use for the content of the button
|
||
|
* if the `buttonLabels` option for MediumEditor is set to 'fontawesome'
|
||
|
*/
|
||
|
contentFA: undefined,
|
||
|
|
||
|
/* classList: [Array]
|
||
|
* An array of classNames (strings) to be added to the button
|
||
|
*/
|
||
|
classList: undefined,
|
||
|
|
||
|
/* attrs: [object]
|
||
|
* A set of key-value pairs to add to the button as custom attributes
|
||
|
*/
|
||
|
attrs: undefined,
|
||
|
|
||
|
// The button constructor can optionally accept the name of a built-in button
|
||
|
// (ie 'bold', 'italic', etc.)
|
||
|
// When the name of a button is passed, it will initialize itself with the
|
||
|
// configuration for that button
|
||
|
constructor: function (options) {
|
||
|
if (Button.isBuiltInButton(options)) {
|
||
|
MediumEditor.Extension.call(this, this.defaults[options]);
|
||
|
} else {
|
||
|
MediumEditor.Extension.call(this, options);
|
||
|
}
|
||
|
},
|
||
|
|
||
|
init: function () {
|
||
|
MediumEditor.Extension.prototype.init.apply(this, arguments);
|
||
|
|
||
|
this.button = this.createButton();
|
||
|
this.on(this.button, 'click', this.handleClick.bind(this));
|
||
|
},
|
||
|
|
||
|
/* getButton: [function ()]
|
||
|
*
|
||
|
* If implemented, this function will be called when
|
||
|
* the toolbar is being created. The DOM Element returned
|
||
|
* by this function will be appended to the toolbar along
|
||
|
* with any other buttons.
|
||
|
*/
|
||
|
getButton: function () {
|
||
|
return this.button;
|
||
|
},
|
||
|
|
||
|
getAction: function () {
|
||
|
return (typeof this.action === 'function') ? this.action(this.base.options) : this.action;
|
||
|
},
|
||
|
|
||
|
getAria: function () {
|
||
|
return (typeof this.aria === 'function') ? this.aria(this.base.options) : this.aria;
|
||
|
},
|
||
|
|
||
|
getTagNames: function () {
|
||
|
return (typeof this.tagNames === 'function') ? this.tagNames(this.base.options) : this.tagNames;
|
||
|
},
|
||
|
|
||
|
createButton: function () {
|
||
|
var button = this.document.createElement('button'),
|
||
|
content = this.contentDefault,
|
||
|
ariaLabel = this.getAria(),
|
||
|
buttonLabels = this.getEditorOption('buttonLabels');
|
||
|
// Add class names
|
||
|
button.classList.add('medium-editor-action');
|
||
|
button.classList.add('medium-editor-action-' + this.name);
|
||
|
if (this.classList) {
|
||
|
this.classList.forEach(function (className) {
|
||
|
button.classList.add(className);
|
||
|
});
|
||
|
}
|
||
|
|
||
|
// Add attributes
|
||
|
button.setAttribute('data-action', this.getAction());
|
||
|
if (ariaLabel) {
|
||
|
button.setAttribute('title', ariaLabel);
|
||
|
button.setAttribute('aria-label', ariaLabel);
|
||
|
}
|
||
|
if (this.attrs) {
|
||
|
Object.keys(this.attrs).forEach(function (attr) {
|
||
|
button.setAttribute(attr, this.attrs[attr]);
|
||
|
}, this);
|
||
|
}
|
||
|
|
||
|
if (buttonLabels === 'fontawesome' && this.contentFA) {
|
||
|
content = this.contentFA;
|
||
|
}
|
||
|
button.innerHTML = content;
|
||
|
return button;
|
||
|
},
|
||
|
|
||
|
handleClick: function (event) {
|
||
|
event.preventDefault();
|
||
|
event.stopPropagation();
|
||
|
|
||
|
var action = this.getAction();
|
||
|
|
||
|
if (action) {
|
||
|
this.execAction(action);
|
||
|
}
|
||
|
},
|
||
|
|
||
|
isActive: function () {
|
||
|
return this.button.classList.contains(this.getEditorOption('activeButtonClass'));
|
||
|
},
|
||
|
|
||
|
setInactive: function () {
|
||
|
this.button.classList.remove(this.getEditorOption('activeButtonClass'));
|
||
|
delete this.knownState;
|
||
|
},
|
||
|
|
||
|
setActive: function () {
|
||
|
this.button.classList.add(this.getEditorOption('activeButtonClass'));
|
||
|
delete this.knownState;
|
||
|
},
|
||
|
|
||
|
queryCommandState: function () {
|
||
|
var queryState = null;
|
||
|
if (this.useQueryState) {
|
||
|
queryState = this.base.queryCommandState(this.getAction());
|
||
|
}
|
||
|
return queryState;
|
||
|
},
|
||
|
|
||
|
isAlreadyApplied: function (node) {
|
||
|
var isMatch = false,
|
||
|
tagNames = this.getTagNames(),
|
||
|
styleVals,
|
||
|
computedStyle;
|
||
|
|
||
|
if (this.knownState === false || this.knownState === true) {
|
||
|
return this.knownState;
|
||
|
}
|
||
|
|
||
|
if (tagNames && tagNames.length > 0) {
|
||
|
isMatch = tagNames.indexOf(node.nodeName.toLowerCase()) !== -1;
|
||
|
}
|
||
|
|
||
|
if (!isMatch && this.style) {
|
||
|
styleVals = this.style.value.split('|');
|
||
|
computedStyle = this.window.getComputedStyle(node, null).getPropertyValue(this.style.prop);
|
||
|
styleVals.forEach(function (val) {
|
||
|
if (!this.knownState) {
|
||
|
isMatch = (computedStyle.indexOf(val) !== -1);
|
||
|
// text-decoration is not inherited by default
|
||
|
// so if the computed style for text-decoration doesn't match
|
||
|
// don't write to knownState so we can fallback to other checks
|
||
|
if (isMatch || this.style.prop !== 'text-decoration') {
|
||
|
this.knownState = isMatch;
|
||
|
}
|
||
|
}
|
||
|
}, this);
|
||
|
}
|
||
|
|
||
|
return isMatch;
|
||
|
}
|
||
|
});
|
||
|
|
||
|
Button.isBuiltInButton = function (name) {
|
||
|
return (typeof name === 'string') && MediumEditor.extensions.button.prototype.defaults.hasOwnProperty(name);
|
||
|
};
|
||
|
|
||
|
MediumEditor.extensions.button = Button;
|
||
|
}());
|
||
|
|
||
|
(function () {
|
||
|
'use strict';
|
||
|
|
||
|
/* MediumEditor.extensions.button.defaults: [Object]
|
||
|
* Set of default config options for all of the built-in MediumEditor buttons
|
||
|
*/
|
||
|
MediumEditor.extensions.button.prototype.defaults = {
|
||
|
'bold': {
|
||
|
name: 'bold',
|
||
|
action: 'bold',
|
||
|
aria: 'bold',
|
||
|
tagNames: ['b', 'strong'],
|
||
|
style: {
|
||
|
prop: 'font-weight',
|
||
|
value: '700|bold'
|
||
|
},
|
||
|
useQueryState: true,
|
||
|
contentDefault: '<b>B</b>',
|
||
|
contentFA: '<i class="fa fa-bold"></i>'
|
||
|
},
|
||
|
'italic': {
|
||
|
name: 'italic',
|
||
|
action: 'italic',
|
||
|
aria: 'italic',
|
||
|
tagNames: ['i', 'em'],
|
||
|
style: {
|
||
|
prop: 'font-style',
|
||
|
value: 'italic'
|
||
|
},
|
||
|
useQueryState: true,
|
||
|
contentDefault: '<b><i>I</i></b>',
|
||
|
contentFA: '<i class="fa fa-italic"></i>'
|
||
|
},
|
||
|
'underline': {
|
||
|
name: 'underline',
|
||
|
action: 'underline',
|
||
|
aria: 'underline',
|
||
|
tagNames: ['u'],
|
||
|
style: {
|
||
|
prop: 'text-decoration',
|
||
|
value: 'underline'
|
||
|
},
|
||
|
useQueryState: true,
|
||
|
contentDefault: '<b><u>U</u></b>',
|
||
|
contentFA: '<i class="fa fa-underline"></i>'
|
||
|
},
|
||
|
'strikethrough': {
|
||
|
name: 'strikethrough',
|
||
|
action: 'strikethrough',
|
||
|
aria: 'strike through',
|
||
|
tagNames: ['strike'],
|
||
|
style: {
|
||
|
prop: 'text-decoration',
|
||
|
value: 'line-through'
|
||
|
},
|
||
|
useQueryState: true,
|
||
|
contentDefault: '<s>A</s>',
|
||
|
contentFA: '<i class="fa fa-strikethrough"></i>'
|
||
|
},
|
||
|
'superscript': {
|
||
|
name: 'superscript',
|
||
|
action: 'superscript',
|
||
|
aria: 'superscript',
|
||
|
tagNames: ['sup'],
|
||
|
/* firefox doesn't behave the way we want it to, so we CAN'T use queryCommandState for superscript
|
||
|
https://github.com/guardian/scribe/blob/master/BROWSERINCONSISTENCIES.md#documentquerycommandstate */
|
||
|
// useQueryState: true
|
||
|
contentDefault: '<b>x<sup>1</sup></b>',
|
||
|
contentFA: '<i class="fa fa-superscript"></i>'
|
||
|
},
|
||
|
'subscript': {
|
||
|
name: 'subscript',
|
||
|
action: 'subscript',
|
||
|
aria: 'subscript',
|
||
|
tagNames: ['sub'],
|
||
|
/* firefox doesn't behave the way we want it to, so we CAN'T use queryCommandState for subscript
|
||
|
https://github.com/guardian/scribe/blob/master/BROWSERINCONSISTENCIES.md#documentquerycommandstate */
|
||
|
// useQueryState: true
|
||
|
contentDefault: '<b>x<sub>1</sub></b>',
|
||
|
contentFA: '<i class="fa fa-subscript"></i>'
|
||
|
},
|
||
|
'image': {
|
||
|
name: 'image',
|
||
|
action: 'image',
|
||
|
aria: 'image',
|
||
|
tagNames: ['img'],
|
||
|
contentDefault: '<b>image</b>',
|
||
|
contentFA: '<i class="fa fa-picture-o"></i>'
|
||
|
},
|
||
|
'html': {
|
||
|
name: 'html',
|
||
|
action: 'html',
|
||
|
aria: 'evaluate html',
|
||
|
tagNames: ['iframe', 'object'],
|
||
|
contentDefault: '<b>html</b>',
|
||
|
contentFA: '<i class="fa fa-code"></i>'
|
||
|
},
|
||
|
'orderedlist': {
|
||
|
name: 'orderedlist',
|
||
|
action: 'insertorderedlist',
|
||
|
aria: 'ordered list',
|
||
|
tagNames: ['ol'],
|
||
|
useQueryState: true,
|
||
|
contentDefault: '<b>1.</b>',
|
||
|
contentFA: '<i class="fa fa-list-ol"></i>'
|
||
|
},
|
||
|
'unorderedlist': {
|
||
|
name: 'unorderedlist',
|
||
|
action: 'insertunorderedlist',
|
||
|
aria: 'unordered list',
|
||
|
tagNames: ['ul'],
|
||
|
useQueryState: true,
|
||
|
contentDefault: '<b>•</b>',
|
||
|
contentFA: '<i class="fa fa-list-ul"></i>'
|
||
|
},
|
||
|
'indent': {
|
||
|
name: 'indent',
|
||
|
action: 'indent',
|
||
|
aria: 'indent',
|
||
|
tagNames: [],
|
||
|
contentDefault: '<b>→</b>',
|
||
|
contentFA: '<i class="fa fa-indent"></i>'
|
||
|
},
|
||
|
'outdent': {
|
||
|
name: 'outdent',
|
||
|
action: 'outdent',
|
||
|
aria: 'outdent',
|
||
|
tagNames: [],
|
||
|
contentDefault: '<b>←</b>',
|
||
|
contentFA: '<i class="fa fa-outdent"></i>'
|
||
|
},
|
||
|
'justifyCenter': {
|
||
|
name: 'justifyCenter',
|
||
|
action: 'justifyCenter',
|
||
|
aria: 'center justify',
|
||
|
tagNames: [],
|
||
|
style: {
|
||
|
prop: 'text-align',
|
||
|
value: 'center'
|
||
|
},
|
||
|
contentDefault: '<b>C</b>',
|
||
|
contentFA: '<i class="fa fa-align-center"></i>'
|
||
|
},
|
||
|
'justifyFull': {
|
||
|
name: 'justifyFull',
|
||
|
action: 'justifyFull',
|
||
|
aria: 'full justify',
|
||
|
tagNames: [],
|
||
|
style: {
|
||
|
prop: 'text-align',
|
||
|
value: 'justify'
|
||
|
},
|
||
|
contentDefault: '<b>J</b>',
|
||
|
contentFA: '<i class="fa fa-align-justify"></i>'
|
||
|
},
|
||
|
'justifyLeft': {
|
||
|
name: 'justifyLeft',
|
||
|
action: 'justifyLeft',
|
||
|
aria: 'left justify',
|
||
|
tagNames: [],
|
||
|
style: {
|
||
|
prop: 'text-align',
|
||
|
value: 'left'
|
||
|
},
|
||
|
contentDefault: '<b>L</b>',
|
||
|
contentFA: '<i class="fa fa-align-left"></i>'
|
||
|
},
|
||
|
'justifyRight': {
|
||
|
name: 'justifyRight',
|
||
|
action: 'justifyRight',
|
||
|
aria: 'right justify',
|
||
|
tagNames: [],
|
||
|
style: {
|
||
|
prop: 'text-align',
|
||
|
value: 'right'
|
||
|
},
|
||
|
contentDefault: '<b>R</b>',
|
||
|
contentFA: '<i class="fa fa-align-right"></i>'
|
||
|
},
|
||
|
// Known inline elements that are not removed, or not removed consistantly across browsers:
|
||
|
// <span>, <label>, <br>
|
||
|
'removeFormat': {
|
||
|
name: 'removeFormat',
|
||
|
aria: 'remove formatting',
|
||
|
action: 'removeFormat',
|
||
|
contentDefault: '<b>X</b>',
|
||
|
contentFA: '<i class="fa fa-eraser"></i>'
|
||
|
},
|
||
|
|
||
|
/***** Buttons for appending block elements (append-<element> action) *****/
|
||
|
|
||
|
'quote': {
|
||
|
name: 'quote',
|
||
|
action: 'append-blockquote',
|
||
|
aria: 'blockquote',
|
||
|
tagNames: ['blockquote'],
|
||
|
contentDefault: '<b>“</b>',
|
||
|
contentFA: '<i class="fa fa-quote-right"></i>'
|
||
|
},
|
||
|
'pre': {
|
||
|
name: 'pre',
|
||
|
action: 'append-pre',
|
||
|
aria: 'preformatted text',
|
||
|
tagNames: ['pre'],
|
||
|
contentDefault: '<b>0101</b>',
|
||
|
contentFA: '<i class="fa fa-code fa-lg"></i>'
|
||
|
},
|
||
|
'h1': {
|
||
|
name: 'h1',
|
||
|
action: 'append-h1',
|
||
|
aria: 'header type one',
|
||
|
tagNames: ['h1'],
|
||
|
contentDefault: '<b>H1</b>',
|
||
|
contentFA: '<i class="fa fa-header"><sup>1</sup>'
|
||
|
},
|
||
|
'h2': {
|
||
|
name: 'h2',
|
||
|
action: 'append-h2',
|
||
|
aria: 'header type two',
|
||
|
tagNames: ['h2'],
|
||
|
contentDefault: '<b>H2</b>',
|
||
|
contentFA: '<i class="fa fa-header"><sup>2</sup>'
|
||
|
},
|
||
|
'h3': {
|
||
|
name: 'h3',
|
||
|
action: 'append-h3',
|
||
|
aria: 'header type three',
|
||
|
tagNames: ['h3'],
|
||
|
contentDefault: '<b>H3</b>',
|
||
|
contentFA: '<i class="fa fa-header"><sup>3</sup>'
|
||
|
},
|
||
|
'h4': {
|
||
|
name: 'h4',
|
||
|
action: 'append-h4',
|
||
|
aria: 'header type four',
|
||
|
tagNames: ['h4'],
|
||
|
contentDefault: '<b>H4</b>',
|
||
|
contentFA: '<i class="fa fa-header"><sup>4</sup>'
|
||
|
},
|
||
|
'h5': {
|
||
|
name: 'h5',
|
||
|
action: 'append-h5',
|
||
|
aria: 'header type five',
|
||
|
tagNames: ['h5'],
|
||
|
contentDefault: '<b>H5</b>',
|
||
|
contentFA: '<i class="fa fa-header"><sup>5</sup>'
|
||
|
},
|
||
|
'h6': {
|
||
|
name: 'h6',
|
||
|
action: 'append-h6',
|
||
|
aria: 'header type six',
|
||
|
tagNames: ['h6'],
|
||
|
contentDefault: '<b>H6</b>',
|
||
|
contentFA: '<i class="fa fa-header"><sup>6</sup>'
|
||
|
}
|
||
|
};
|
||
|
|
||
|
})();
|
||
|
|
||
|
(function () {
|
||
|
'use strict';
|
||
|
|
||
|
/* Base functionality for an extension which will display
|
||
|
* a 'form' inside the toolbar
|
||
|
*/
|
||
|
var FormExtension = MediumEditor.extensions.button.extend({
|
||
|
|
||
|
init: function () {
|
||
|
MediumEditor.extensions.button.prototype.init.apply(this, arguments);
|
||
|
},
|
||
|
|
||
|
// default labels for the form buttons
|
||
|
formSaveLabel: '✓',
|
||
|
formCloseLabel: '×',
|
||
|
|
||
|
/* activeClass: [string]
|
||
|
* set class which added to shown form
|
||
|
*/
|
||
|
activeClass: 'medium-editor-toolbar-form-active',
|
||
|
|
||
|
/* hasForm: [boolean]
|
||
|
*
|
||
|
* Setting this to true will cause getForm() to be called
|
||
|
* when the toolbar is created, so the form can be appended
|
||
|
* inside the toolbar container
|
||
|
*/
|
||
|
hasForm: true,
|
||
|
|
||
|
/* getForm: [function ()]
|
||
|
*
|
||
|
* When hasForm is true, this function must be implemented
|
||
|
* and return a DOM Element which will be appended to
|
||
|
* the toolbar container. The form should start hidden, and
|
||
|
* the extension can choose when to hide/show it
|
||
|
*/
|
||
|
getForm: function () {},
|
||
|
|
||
|
/* isDisplayed: [function ()]
|
||
|
*
|
||
|
* This function should return true/false reflecting
|
||
|
* whether the form is currently displayed
|
||
|
*/
|
||
|
isDisplayed: function () {
|
||
|
if (this.hasForm) {
|
||
|
return this.getForm().classList.contains(this.activeClass);
|
||
|
}
|
||
|
return false;
|
||
|
},
|
||
|
|
||
|
/* hideForm: [function ()]
|
||
|
*
|
||
|
* This function should show the form element inside
|
||
|
* the toolbar container
|
||
|
*/
|
||
|
showForm: function () {
|
||
|
if (this.hasForm) {
|
||
|
this.getForm().classList.add(this.activeClass);
|
||
|
}
|
||
|
},
|
||
|
|
||
|
/* hideForm: [function ()]
|
||
|
*
|
||
|
* This function should hide the form element inside
|
||
|
* the toolbar container
|
||
|
*/
|
||
|
hideForm: function () {
|
||
|
if (this.hasForm) {
|
||
|
this.getForm().classList.remove(this.activeClass);
|
||
|
}
|
||
|
},
|
||
|
|
||
|
/************************ 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 form extensions
|
||
|
*********************************************************/
|
||
|
|
||
|
/* showToolbarDefaultActions: [function ()]
|
||
|
*
|
||
|
* Helper method which will turn back the toolbar after canceling
|
||
|
* the customized form
|
||
|
*/
|
||
|
showToolbarDefaultActions: function () {
|
||
|
var toolbar = this.base.getExtensionByName('toolbar');
|
||
|
if (toolbar) {
|
||
|
toolbar.showToolbarDefaultActions();
|
||
|
}
|
||
|
},
|
||
|
|
||
|
/* hideToolbarDefaultActions: [function ()]
|
||
|
*
|
||
|
* Helper function which will hide the default contents of the
|
||
|
* toolbar, but leave the toolbar container in the same state
|
||
|
* to allow a form to display its custom contents inside the toolbar
|
||
|
*/
|
||
|
hideToolbarDefaultActions: function () {
|
||
|
var toolbar = this.base.getExtensionByName('toolbar');
|
||
|
if (toolbar) {
|
||
|
toolbar.hideToolbarDefaultActions();
|
||
|
}
|
||
|
},
|
||
|
|
||
|
/* setToolbarPosition: [function ()]
|
||
|
*
|
||
|
* Helper function which will update the size and position
|
||
|
* of the toolbar based on the toolbar content and the current
|
||
|
* position of the user's selection
|
||
|
*/
|
||
|
setToolbarPosition: function () {
|
||
|
var toolbar = this.base.getExtensionByName('toolbar');
|
||
|
if (toolbar) {
|
||
|
toolbar.setToolbarPosition();
|
||
|
}
|
||
|
}
|
||
|
});
|
||
|
|
||
|
MediumEditor.extensions.form = FormExtension;
|
||
|
})();
|
||
|
(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;
|
||
|
}());
|
||
|
|
||
|
(function () {
|
||
|
'use strict';
|
||
|
|
||
|
var AnchorPreview = MediumEditor.Extension.extend({
|
||
|
name: 'anchor-preview',
|
||
|
|
||
|
// Anchor Preview Options
|
||
|
|
||
|
/* hideDelay: [number] (previously options.anchorPreviewHideDelay)
|
||
|
* time in milliseconds to show the anchor tag preview after the mouse has left the anchor tag.
|
||
|
*/
|
||
|
hideDelay: 500,
|
||
|
|
||
|
/* previewValueSelector: [string]
|
||
|
* the default selector to locate where to put the activeAnchor value in the preview
|
||
|
*/
|
||
|
previewValueSelector: 'a',
|
||
|
|
||
|
/* showWhenToolbarIsVisible: [boolean]
|
||
|
* determines whether the anchor tag preview shows up when the toolbar is visible
|
||
|
*/
|
||
|
showWhenToolbarIsVisible: false,
|
||
|
|
||
|
/* showOnEmptyLinks: [boolean]
|
||
|
* determines whether the anchor tag preview shows up on links with href="" or href="#something"
|
||
|
*/
|
||
|
showOnEmptyLinks: true,
|
||
|
|
||
|
init: function () {
|
||
|
this.anchorPreview = this.createPreview();
|
||
|
|
||
|
this.getEditorOption('elementsContainer').appendChild(this.anchorPreview);
|
||
|
|
||
|
this.attachToEditables();
|
||
|
},
|
||
|
|
||
|
getInteractionElements: function () {
|
||
|
return this.getPreviewElement();
|
||
|
},
|
||
|
|
||
|
// TODO: Remove this function in 6.0.0
|
||
|
getPreviewElement: function () {
|
||
|
return this.anchorPreview;
|
||
|
},
|
||
|
|
||
|
createPreview: function () {
|
||
|
var el = this.document.createElement('div');
|
||
|
|
||
|
el.id = 'medium-editor-anchor-preview-' + this.getEditorId();
|
||
|
el.className = 'medium-editor-anchor-preview';
|
||
|
el.innerHTML = this.getTemplate();
|
||
|
|
||
|
this.on(el, 'click', this.handleClick.bind(this));
|
||
|
|
||
|
return el;
|
||
|
},
|
||
|
|
||
|
getTemplate: function () {
|
||
|
return '<div class="medium-editor-toolbar-anchor-preview" id="medium-editor-toolbar-anchor-preview">' +
|
||
|
' <a class="medium-editor-toolbar-anchor-preview-inner"></a>' +
|
||
|
'</div>';
|
||
|
},
|
||
|
|
||
|
destroy: function () {
|
||
|
if (this.anchorPreview) {
|
||
|
if (this.anchorPreview.parentNode) {
|
||
|
this.anchorPreview.parentNode.removeChild(this.anchorPreview);
|
||
|
}
|
||
|
delete this.anchorPreview;
|
||
|
}
|
||
|
},
|
||
|
|
||
|
hidePreview: function () {
|
||
|
if (this.anchorPreview) {
|
||
|
this.anchorPreview.classList.remove('medium-editor-anchor-preview-active');
|
||
|
}
|
||
|
this.activeAnchor = null;
|
||
|
},
|
||
|
|
||
|
showPreview: function (anchorEl) {
|
||
|
if (this.anchorPreview.classList.contains('medium-editor-anchor-preview-active') ||
|
||
|
anchorEl.getAttribute('data-disable-preview')) {
|
||
|
return true;
|
||
|
}
|
||
|
|
||
|
if (this.previewValueSelector) {
|
||
|
this.anchorPreview.querySelector(this.previewValueSelector).textContent = anchorEl.attributes.href.value;
|
||
|
this.anchorPreview.querySelector(this.previewValueSelector).href = anchorEl.attributes.href.value;
|
||
|
}
|
||
|
|
||
|
this.anchorPreview.classList.add('medium-toolbar-arrow-over');
|
||
|
this.anchorPreview.classList.remove('medium-toolbar-arrow-under');
|
||
|
|
||
|
if (!this.anchorPreview.classList.contains('medium-editor-anchor-preview-active')) {
|
||
|
this.anchorPreview.classList.add('medium-editor-anchor-preview-active');
|
||
|
}
|
||
|
|
||
|
this.activeAnchor = anchorEl;
|
||
|
|
||
|
this.positionPreview();
|
||
|
this.attachPreviewHandlers();
|
||
|
|
||
|
return this;
|
||
|
},
|
||
|
|
||
|
positionPreview: function (activeAnchor) {
|
||
|
activeAnchor = activeAnchor || this.activeAnchor;
|
||
|
var containerWidth = this.window.innerWidth,
|
||
|
buttonHeight = this.anchorPreview.offsetHeight,
|
||
|
boundary = activeAnchor.getBoundingClientRect(),
|
||
|
diffLeft = this.diffLeft,
|
||
|
diffTop = this.diffTop,
|
||
|
elementsContainer = this.getEditorOption('elementsContainer'),
|
||
|
elementsContainerAbsolute = ['absolute', 'fixed'].indexOf(window.getComputedStyle(elementsContainer).getPropertyValue('position')) > -1,
|
||
|
relativeBoundary = {},
|
||
|
halfOffsetWidth, defaultLeft, middleBoundary, elementsContainerBoundary, top;
|
||
|
|
||
|
halfOffsetWidth = this.anchorPreview.offsetWidth / 2;
|
||
|
var toolbarExtension = this.base.getExtensionByName('toolbar');
|
||
|
if (toolbarExtension) {
|
||
|
diffLeft = toolbarExtension.diffLeft;
|
||
|
diffTop = toolbarExtension.diffTop;
|
||
|
}
|
||
|
defaultLeft = diffLeft - halfOffsetWidth;
|
||
|
|
||
|
// If container element is absolute / fixed, recalculate boundaries to be relative to the container
|
||
|
if (elementsContainerAbsolute) {
|
||
|
elementsContainerBoundary = elementsContainer.getBoundingClientRect();
|
||
|
['top', 'left'].forEach(function (key) {
|
||
|
relativeBoundary[key] = boundary[key] - elementsContainerBoundary[key];
|
||
|
});
|
||
|
|
||
|
relativeBoundary.width = boundary.width;
|
||
|
relativeBoundary.height = boundary.height;
|
||
|
boundary = relativeBoundary;
|
||
|
|
||
|
containerWidth = elementsContainerBoundary.width;
|
||
|
|
||
|
// Adjust top position according to container scroll position
|
||
|
top = elementsContainer.scrollTop;
|
||
|
} else {
|
||
|
// Adjust top position according to window scroll position
|
||
|
top = this.window.pageYOffset;
|
||
|
}
|
||
|
|
||
|
middleBoundary = boundary.left + boundary.width / 2;
|
||
|
top += buttonHeight + boundary.top + boundary.height - diffTop - this.anchorPreview.offsetHeight;
|
||
|
|
||
|
this.anchorPreview.style.top = Math.round(top) + 'px';
|
||
|
this.anchorPreview.style.right = 'initial';
|
||
|
if (middleBoundary < halfOffsetWidth) {
|
||
|
this.anchorPreview.style.left = defaultLeft + halfOffsetWidth + 'px';
|
||
|
this.anchorPreview.style.right = 'initial';
|
||
|
} else if ((containerWidth - middleBoundary) < halfOffsetWidth) {
|
||
|
this.anchorPreview.style.left = 'auto';
|
||
|
this.anchorPreview.style.right = 0;
|
||
|
} else {
|
||
|
this.anchorPreview.style.left = defaultLeft + middleBoundary + 'px';
|
||
|
this.anchorPreview.style.right = 'initial';
|
||
|
}
|
||
|
},
|
||
|
|
||
|
attachToEditables: function () {
|
||
|
this.subscribe('editableMouseover', this.handleEditableMouseover.bind(this));
|
||
|
this.subscribe('positionedToolbar', this.handlePositionedToolbar.bind(this));
|
||
|
},
|
||
|
|
||
|
handlePositionedToolbar: function () {
|
||
|
// If the toolbar is visible and positioned, we don't need to hide the preview
|
||
|
// when showWhenToolbarIsVisible is true
|
||
|
if (!this.showWhenToolbarIsVisible) {
|
||
|
this.hidePreview();
|
||
|
}
|
||
|
},
|
||
|
|
||
|
handleClick: function (event) {
|
||
|
var anchorExtension = this.base.getExtensionByName('anchor'),
|
||
|
activeAnchor = this.activeAnchor;
|
||
|
|
||
|
if (anchorExtension && activeAnchor) {
|
||
|
event.preventDefault();
|
||
|
|
||
|
this.base.selectElement(this.activeAnchor);
|
||
|
|
||
|
// Using setTimeout + delay because:
|
||
|
// We may actually be displaying the anchor form, which should be controlled by delay
|
||
|
this.base.delay(function () {
|
||
|
if (activeAnchor) {
|
||
|
var opts = {
|
||
|
value: activeAnchor.attributes.href.value,
|
||
|
target: activeAnchor.getAttribute('target'),
|
||
|
buttonClass: activeAnchor.getAttribute('class')
|
||
|
};
|
||
|
anchorExtension.showForm(opts);
|
||
|
activeAnchor = null;
|
||
|
}
|
||
|
}.bind(this));
|
||
|
}
|
||
|
|
||
|
this.hidePreview();
|
||
|
},
|
||
|
|
||
|
handleAnchorMouseout: function () {
|
||
|
this.anchorToPreview = null;
|
||
|
this.off(this.activeAnchor, 'mouseout', this.instanceHandleAnchorMouseout);
|
||
|
this.instanceHandleAnchorMouseout = null;
|
||
|
},
|
||
|
|
||
|
handleEditableMouseover: function (event) {
|
||
|
var target = MediumEditor.util.getClosestTag(event.target, 'a');
|
||
|
|
||
|
if (false === target) {
|
||
|
return;
|
||
|
}
|
||
|
|
||
|
// Detect empty href attributes
|
||
|
// The browser will make href="" or href="#top"
|
||
|
// into absolute urls when accessed as event.target.href, so check the html
|
||
|
if (!this.showOnEmptyLinks &&
|
||
|
(!/href=["']\S+["']/.test(target.outerHTML) || /href=["']#\S+["']/.test(target.outerHTML))) {
|
||
|
return true;
|
||
|
}
|
||
|
|
||
|
// only show when toolbar is not present
|
||
|
var toolbar = this.base.getExtensionByName('toolbar');
|
||
|
if (!this.showWhenToolbarIsVisible && toolbar && toolbar.isDisplayed && toolbar.isDisplayed()) {
|
||
|
return true;
|
||
|
}
|
||
|
|
||
|
// detach handler for other anchor in case we hovered multiple anchors quickly
|
||
|
if (this.activeAnchor && this.activeAnchor !== target) {
|
||
|
this.detachPreviewHandlers();
|
||
|
}
|
||
|
|
||
|
this.anchorToPreview = target;
|
||
|
|
||
|
this.instanceHandleAnchorMouseout = this.handleAnchorMouseout.bind(this);
|
||
|
this.on(this.anchorToPreview, 'mouseout', this.instanceHandleAnchorMouseout);
|
||
|
// Using setTimeout + delay because:
|
||
|
// - We're going to show the anchor preview according to the configured delay
|
||
|
// if the mouse has not left the anchor tag in that time
|
||
|
this.base.delay(function () {
|
||
|
if (this.anchorToPreview) {
|
||
|
this.showPreview(this.anchorToPreview);
|
||
|
}
|
||
|
}.bind(this));
|
||
|
},
|
||
|
|
||
|
handlePreviewMouseover: function () {
|
||
|
this.lastOver = (new Date()).getTime();
|
||
|
this.hovering = true;
|
||
|
},
|
||
|
|
||
|
handlePreviewMouseout: function (event) {
|
||
|
if (!event.relatedTarget || !/anchor-preview/.test(event.relatedTarget.className)) {
|
||
|
this.hovering = false;
|
||
|
}
|
||
|
},
|
||
|
|
||
|
updatePreview: function () {
|
||
|
if (this.hovering) {
|
||
|
return true;
|
||
|
}
|
||
|
var durr = (new Date()).getTime() - this.lastOver;
|
||
|
if (durr > this.hideDelay) {
|
||
|
// hide the preview 1/2 second after mouse leaves the link
|
||
|
this.detachPreviewHandlers();
|
||
|
}
|
||
|
},
|
||
|
|
||
|
detachPreviewHandlers: function () {
|
||
|
// cleanup
|
||
|
clearInterval(this.intervalTimer);
|
||
|
if (this.instanceHandlePreviewMouseover) {
|
||
|
this.off(this.anchorPreview, 'mouseover', this.instanceHandlePreviewMouseover);
|
||
|
this.off(this.anchorPreview, 'mouseout', this.instanceHandlePreviewMouseout);
|
||
|
if (this.activeAnchor) {
|
||
|
this.off(this.activeAnchor, 'mouseover', this.instanceHandlePreviewMouseover);
|
||
|
this.off(this.activeAnchor, 'mouseout', this.instanceHandlePreviewMouseout);
|
||
|
}
|
||
|
}
|
||
|
|
||
|
this.hidePreview();
|
||
|
|
||
|
this.hovering = this.instanceHandlePreviewMouseover = this.instanceHandlePreviewMouseout = null;
|
||
|
},
|
||
|
|
||
|
// TODO: break up method and extract out handlers
|
||
|
attachPreviewHandlers: function () {
|
||
|
this.lastOver = (new Date()).getTime();
|
||
|
this.hovering = true;
|
||
|
|
||
|
this.instanceHandlePreviewMouseover = this.handlePreviewMouseover.bind(this);
|
||
|
this.instanceHandlePreviewMouseout = this.handlePreviewMouseout.bind(this);
|
||
|
|
||
|
this.intervalTimer = setInterval(this.updatePreview.bind(this), 200);
|
||
|
|
||
|
this.on(this.anchorPreview, 'mouseover', this.instanceHandlePreviewMouseover);
|
||
|
this.on(this.anchorPreview, 'mouseout', this.instanceHandlePreviewMouseout);
|
||
|
this.on(this.activeAnchor, 'mouseover', this.instanceHandlePreviewMouseover);
|
||
|
this.on(this.activeAnchor, 'mouseout', this.instanceHandlePreviewMouseout);
|
||
|
}
|
||
|
});
|
||
|
|
||
|
MediumEditor.extensions.anchorPreview = AnchorPreview;
|
||
|
}());
|
||
|
|
||
|
(function () {
|
||
|
'use strict';
|
||
|
|
||
|
var WHITESPACE_CHARS,
|
||
|
KNOWN_TLDS_FRAGMENT,
|
||
|
LINK_REGEXP_TEXT,
|
||
|
KNOWN_TLDS_REGEXP,
|
||
|
LINK_REGEXP;
|
||
|
|
||
|
WHITESPACE_CHARS = [' ', '\t', '\n', '\r', '\u00A0', '\u2000', '\u2001', '\u2002', '\u2003',
|
||
|
'\u2028', '\u2029'];
|
||
|
KNOWN_TLDS_FRAGMENT = 'com|net|org|edu|gov|mil|aero|asia|biz|cat|coop|info|int|jobs|mobi|museum|name|post|pro|tel|travel|' +
|
||
|
'xxx|ac|ad|ae|af|ag|ai|al|am|an|ao|aq|ar|as|at|au|aw|ax|az|ba|bb|bd|be|bf|bg|bh|bi|bj|bm|bn|bo|br|bs|bt|bv|bw|by|' +
|
||
|
'bz|ca|cc|cd|cf|cg|ch|ci|ck|cl|cm|cn|co|cr|cs|cu|cv|cx|cy|cz|dd|de|dj|dk|dm|do|dz|ec|ee|eg|eh|er|es|et|eu|fi|fj|' +
|
||
|
'fk|fm|fo|fr|ga|gb|gd|ge|gf|gg|gh|gi|gl|gm|gn|gp|gq|gr|gs|gt|gu|gw|gy|hk|hm|hn|hr|ht|hu|id|ie|il|im|in|io|iq|ir|' +
|
||
|
'is|it|je|jm|jo|jp|ke|kg|kh|ki|km|kn|kp|kr|kw|ky|kz|la|lb|lc|li|lk|lr|ls|lt|lu|lv|ly|ma|mc|md|me|mg|mh|mk|ml|mm|' +
|
||
|
'mn|mo|mp|mq|mr|ms|mt|mu|mv|mw|mx|my|mz|na|nc|ne|nf|ng|ni|nl|no|np|nr|nu|nz|om|pa|pe|pf|pg|ph|pk|pl|pm|pn|pr|ps|' +
|
||
|
'pt|pw|py|qa|re|ro|rs|ru|rw|sa|sb|sc|sd|se|sg|sh|si|sj|ja|sk|sl|sm|sn|so|sr|ss|st|su|sv|sx|sy|sz|tc|td|tf|tg|th|' +
|
||
|
'tj|tk|tl|tm|tn|to|tp|tr|tt|tv|tw|tz|ua|ug|uk|us|uy|uz|va|vc|ve|vg|vi|vn|vu|wf|ws|ye|yt|yu|za|zm|zw';
|
||
|
|
||
|
LINK_REGEXP_TEXT =
|
||
|
'(' +
|
||
|
// Version of Gruber URL Regexp optimized for JS: http://stackoverflow.com/a/17733640
|
||
|
'((?:(https?://|ftps?://|nntp://)|www\\d{0,3}[.]|[a-z0-9.\\-]+[.](' + KNOWN_TLDS_FRAGMENT + ')\\\/)\\S+(?:[^\\s`!\\[\\]{};:\'\".,?\u00AB\u00BB\u201C\u201D\u2018\u2019]))' +
|
||
|
// Addition to above Regexp to support bare domains/one level subdomains with common non-i18n TLDs and without www prefix:
|
||
|
')|(([a-z0-9\\-]+\\.)?[a-z0-9\\-]+\\.(' + KNOWN_TLDS_FRAGMENT + '))';
|
||
|
|
||
|
KNOWN_TLDS_REGEXP = new RegExp('^(' + KNOWN_TLDS_FRAGMENT + ')$', 'i');
|
||
|
|
||
|
LINK_REGEXP = new RegExp(LINK_REGEXP_TEXT, 'gi');
|
||
|
|
||
|
function nodeIsNotInsideAnchorTag(node) {
|
||
|
return !MediumEditor.util.getClosestTag(node, 'a');
|
||
|
}
|
||
|
|
||
|
var AutoLink = MediumEditor.Extension.extend({
|
||
|
init: function () {
|
||
|
MediumEditor.Extension.prototype.init.apply(this, arguments);
|
||
|
|
||
|
this.disableEventHandling = false;
|
||
|
this.subscribe('editableKeypress', this.onKeypress.bind(this));
|
||
|
this.subscribe('editableBlur', this.onBlur.bind(this));
|
||
|
// MS IE has it's own auto-URL detect feature but ours is better in some ways. Be consistent.
|
||
|
this.document.execCommand('AutoUrlDetect', false, false);
|
||
|
},
|
||
|
|
||
|
isLastInstance: function () {
|
||
|
var activeInstances = 0;
|
||
|
for (var i = 0; i < this.window._mediumEditors.length; i++) {
|
||
|
var editor = this.window._mediumEditors[i];
|
||
|
if (editor !== null && editor.getExtensionByName('autoLink') !== undefined) {
|
||
|
activeInstances++;
|
||
|
}
|
||
|
}
|
||
|
return activeInstances === 1;
|
||
|
},
|
||
|
|
||
|
destroy: function () {
|
||
|
// Turn AutoUrlDetect back on
|
||
|
if (this.document.queryCommandSupported('AutoUrlDetect') && this.isLastInstance()) {
|
||
|
this.document.execCommand('AutoUrlDetect', false, true);
|
||
|
}
|
||
|
},
|
||
|
|
||
|
onBlur: function (blurEvent, editable) {
|
||
|
this.performLinking(editable);
|
||
|
},
|
||
|
|
||
|
onKeypress: function (keyPressEvent) {
|
||
|
if (this.disableEventHandling) {
|
||
|
return;
|
||
|
}
|
||
|
|
||
|
if (MediumEditor.util.isKey(keyPressEvent, [MediumEditor.util.keyCode.SPACE, MediumEditor.util.keyCode.ENTER])) {
|
||
|
clearTimeout(this.performLinkingTimeout);
|
||
|
// Saving/restoring the selection in the middle of a keypress doesn't work well...
|
||
|
this.performLinkingTimeout = setTimeout(function () {
|
||
|
try {
|
||
|
var sel = this.base.exportSelection();
|
||
|
if (this.performLinking(keyPressEvent.target)) {
|
||
|
// pass true for favorLaterSelectionAnchor - this is needed for links at the end of a
|
||
|
// paragraph in MS IE, or MS IE causes the link to be deleted right after adding it.
|
||
|
this.base.importSelection(sel, true);
|
||
|
}
|
||
|
} catch (e) {
|
||
|
if (window.console) {
|
||
|
window.console.error('Failed to perform linking', e);
|
||
|
}
|
||
|
this.disableEventHandling = true;
|
||
|
}
|
||
|
}.bind(this), 0);
|
||
|
}
|
||
|
},
|
||
|
|
||
|
performLinking: function (contenteditable) {
|
||
|
/*
|
||
|
Perform linking on blockElement basis, blockElements are HTML elements with text content and without
|
||
|
child element.
|
||
|
|
||
|
Example:
|
||
|
- HTML content
|
||
|
<blockquote>
|
||
|
<p>link.</p>
|
||
|
<p>my</p>
|
||
|
</blockquote>
|
||
|
|
||
|
- blockElements
|
||
|
[<p>link.</p>, <p>my</p>]
|
||
|
|
||
|
otherwise the detection can wrongly find the end of one paragraph and the beginning of another paragraph
|
||
|
to constitute a link, such as a paragraph ending "link." and the next paragraph beginning with "my" is
|
||
|
interpreted into "link.my" and the code tries to create a link across blockElements - which doesn't work
|
||
|
and is terrible.
|
||
|
(Medium deletes the spaces/returns between P tags so the textContent ends up without paragraph spacing)
|
||
|
*/
|
||
|
var blockElements = MediumEditor.util.splitByBlockElements(contenteditable),
|
||
|
documentModified = false;
|
||
|
if (blockElements.length === 0) {
|
||
|
blockElements = [contenteditable];
|
||
|
}
|
||
|
for (var i = 0; i < blockElements.length; i++) {
|
||
|
documentModified = this.removeObsoleteAutoLinkSpans(blockElements[i]) || documentModified;
|
||
|
documentModified = this.performLinkingWithinElement(blockElements[i]) || documentModified;
|
||
|
}
|
||
|
this.base.events.updateInput(contenteditable, { target: contenteditable, currentTarget: contenteditable });
|
||
|
return documentModified;
|
||
|
},
|
||
|
|
||
|
removeObsoleteAutoLinkSpans: function (element) {
|
||
|
if (!element || element.nodeType === 3) {
|
||
|
return false;
|
||
|
}
|
||
|
|
||
|
var spans = element.querySelectorAll('span[data-auto-link="true"]'),
|
||
|
documentModified = false;
|
||
|
|
||
|
for (var i = 0; i < spans.length; i++) {
|
||
|
var textContent = spans[i].textContent;
|
||
|
if (textContent.indexOf('://') === -1) {
|
||
|
textContent = MediumEditor.util.ensureUrlHasProtocol(textContent);
|
||
|
}
|
||
|
if (spans[i].getAttribute('data-href') !== textContent && nodeIsNotInsideAnchorTag(spans[i])) {
|
||
|
documentModified = true;
|
||
|
var trimmedTextContent = textContent.replace(/\s+$/, '');
|
||
|
if (spans[i].getAttribute('data-href') === trimmedTextContent) {
|
||
|
var charactersTrimmed = textContent.length - trimmedTextContent.length,
|
||
|
subtree = MediumEditor.util.splitOffDOMTree(spans[i], this.splitTextBeforeEnd(spans[i], charactersTrimmed));
|
||
|
spans[i].parentNode.insertBefore(subtree, spans[i].nextSibling);
|
||
|
} else {
|
||
|
// Some editing has happened to the span, so just remove it entirely. The user can put it back
|
||
|
// around just the href content if they need to prevent it from linking
|
||
|
MediumEditor.util.unwrap(spans[i], this.document);
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
return documentModified;
|
||
|
},
|
||
|
|
||
|
splitTextBeforeEnd: function (element, characterCount) {
|
||
|
var treeWalker = this.document.createTreeWalker(element, NodeFilter.SHOW_TEXT, null, false),
|
||
|
lastChildNotExhausted = true;
|
||
|
|
||
|
// Start the tree walker at the last descendant of the span
|
||
|
while (lastChildNotExhausted) {
|
||
|
lastChildNotExhausted = treeWalker.lastChild() !== null;
|
||
|
}
|
||
|
|
||
|
var currentNode,
|
||
|
currentNodeValue,
|
||
|
previousNode;
|
||
|
while (characterCount > 0 && previousNode !== null) {
|
||
|
currentNode = treeWalker.currentNode;
|
||
|
currentNodeValue = currentNode.nodeValue;
|
||
|
if (currentNodeValue.length > characterCount) {
|
||
|
previousNode = currentNode.splitText(currentNodeValue.length - characterCount);
|
||
|
characterCount = 0;
|
||
|
} else {
|
||
|
previousNode = treeWalker.previousNode();
|
||
|
characterCount -= currentNodeValue.length;
|
||
|
}
|
||
|
}
|
||
|
return previousNode;
|
||
|
},
|
||
|
|
||
|
performLinkingWithinElement: function (element) {
|
||
|
var matches = this.findLinkableText(element),
|
||
|
linkCreated = false;
|
||
|
|
||
|
for (var matchIndex = 0; matchIndex < matches.length; matchIndex++) {
|
||
|
var matchingTextNodes = MediumEditor.util.findOrCreateMatchingTextNodes(this.document, element,
|
||
|
matches[matchIndex]);
|
||
|
if (this.shouldNotLink(matchingTextNodes)) {
|
||
|
continue;
|
||
|
}
|
||
|
this.createAutoLink(matchingTextNodes, matches[matchIndex].href);
|
||
|
}
|
||
|
return linkCreated;
|
||
|
},
|
||
|
|
||
|
shouldNotLink: function (textNodes) {
|
||
|
var shouldNotLink = false;
|
||
|
for (var i = 0; i < textNodes.length && shouldNotLink === false; i++) {
|
||
|
// Do not link if the text node is either inside an anchor or inside span[data-auto-link]
|
||
|
shouldNotLink = !!MediumEditor.util.traverseUp(textNodes[i], function (node) {
|
||
|
return node.nodeName.toLowerCase() === 'a' ||
|
||
|
(node.getAttribute && node.getAttribute('data-auto-link') === 'true');
|
||
|
});
|
||
|
}
|
||
|
return shouldNotLink;
|
||
|
},
|
||
|
|
||
|
findLinkableText: function (contenteditable) {
|
||
|
var textContent = contenteditable.textContent,
|
||
|
match = null,
|
||
|
matches = [];
|
||
|
|
||
|
while ((match = LINK_REGEXP.exec(textContent)) !== null) {
|
||
|
var matchOk = true,
|
||
|
matchEnd = match.index + match[0].length;
|
||
|
// If the regexp detected something as a link that has text immediately preceding/following it, bail out.
|
||
|
matchOk = (match.index === 0 || WHITESPACE_CHARS.indexOf(textContent[match.index - 1]) !== -1) &&
|
||
|
(matchEnd === textContent.length || WHITESPACE_CHARS.indexOf(textContent[matchEnd]) !== -1);
|
||
|
// If the regexp detected a bare domain that doesn't use one of our expected TLDs, bail out.
|
||
|
matchOk = matchOk && (match[0].indexOf('/') !== -1 ||
|
||
|
KNOWN_TLDS_REGEXP.test(match[0].split('.').pop().split('?').shift()));
|
||
|
|
||
|
if (matchOk) {
|
||
|
matches.push({
|
||
|
href: match[0],
|
||
|
start: match.index,
|
||
|
end: matchEnd
|
||
|
});
|
||
|
}
|
||
|
}
|
||
|
return matches;
|
||
|
},
|
||
|
|
||
|
createAutoLink: function (textNodes, href) {
|
||
|
href = MediumEditor.util.ensureUrlHasProtocol(href);
|
||
|
var anchor = MediumEditor.util.createLink(this.document, textNodes, href, this.getEditorOption('targetBlank') ? '_blank' : null),
|
||
|
span = this.document.createElement('span');
|
||
|
span.setAttribute('data-auto-link', 'true');
|
||
|
span.setAttribute('data-href', href);
|
||
|
anchor.insertBefore(span, anchor.firstChild);
|
||
|
while (anchor.childNodes.length > 1) {
|
||
|
span.appendChild(anchor.childNodes[1]);
|
||
|
}
|
||
|
}
|
||
|
|
||
|
});
|
||
|
|
||
|
MediumEditor.extensions.autoLink = AutoLink;
|
||
|
}());
|
||
|
|
||
|
(function () {
|
||
|
'use strict';
|
||
|
|
||
|
var CLASS_DRAG_OVER = 'medium-editor-dragover';
|
||
|
|
||
|
function clearClassNames(element) {
|
||
|
var editable = MediumEditor.util.getContainerEditorElement(element),
|
||
|
existing = Array.prototype.slice.call(editable.parentElement.querySelectorAll('.' + CLASS_DRAG_OVER));
|
||
|
|
||
|
existing.forEach(function (el) {
|
||
|
el.classList.remove(CLASS_DRAG_OVER);
|
||
|
});
|
||
|
}
|
||
|
|
||
|
var FileDragging = MediumEditor.Extension.extend({
|
||
|
name: 'fileDragging',
|
||
|
|
||
|
allowedTypes: ['image'],
|
||
|
|
||
|
init: function () {
|
||
|
MediumEditor.Extension.prototype.init.apply(this, arguments);
|
||
|
|
||
|
this.subscribe('editableDrag', this.handleDrag.bind(this));
|
||
|
this.subscribe('editableDrop', this.handleDrop.bind(this));
|
||
|
},
|
||
|
|
||
|
handleDrag: function (event) {
|
||
|
event.preventDefault();
|
||
|
event.dataTransfer.dropEffect = 'copy';
|
||
|
|
||
|
var target = event.target.classList ? event.target : event.target.parentElement;
|
||
|
|
||
|
// Ensure the class gets removed from anything that had it before
|
||
|
clearClassNames(target);
|
||
|
|
||
|
if (event.type === 'dragover') {
|
||
|
target.classList.add(CLASS_DRAG_OVER);
|
||
|
}
|
||
|
},
|
||
|
|
||
|
handleDrop: function (event) {
|
||
|
// Prevent file from opening in the current window
|
||
|
event.preventDefault();
|
||
|
event.stopPropagation();
|
||
|
// Select the dropping target, and set the selection to the end of the target
|
||
|
// https://github.com/yabwe/medium-editor/issues/980
|
||
|
this.base.selectElement(event.target);
|
||
|
var selection = this.base.exportSelection();
|
||
|
selection.start = selection.end;
|
||
|
this.base.importSelection(selection);
|
||
|
// IE9 does not support the File API, so prevent file from opening in the window
|
||
|
// but also don't try to actually get the file
|
||
|
if (event.dataTransfer.files) {
|
||
|
Array.prototype.slice.call(event.dataTransfer.files).forEach(function (file) {
|
||
|
if (this.isAllowedFile(file)) {
|
||
|
if (file.type.match('image')) {
|
||
|
this.insertImageFile(file);
|
||
|
}
|
||
|
}
|
||
|
}, this);
|
||
|
}
|
||
|
|
||
|
// Make sure we remove our class from everything
|
||
|
clearClassNames(event.target);
|
||
|
},
|
||
|
|
||
|
isAllowedFile: function (file) {
|
||
|
return this.allowedTypes.some(function (fileType) {
|
||
|
return !!file.type.match(fileType);
|
||
|
});
|
||
|
},
|
||
|
|
||
|
insertImageFile: function (file) {
|
||
|
if (typeof FileReader !== 'function') {
|
||
|
return;
|
||
|
}
|
||
|
var fileReader = new FileReader();
|
||
|
fileReader.readAsDataURL(file);
|
||
|
|
||
|
// attach the onload event handler, makes it easier to listen in with jasmine
|
||
|
fileReader.addEventListener('load', function (e) {
|
||
|
var addImageElement = this.document.createElement('img');
|
||
|
addImageElement.src = e.target.result;
|
||
|
MediumEditor.util.insertHTMLCommand(this.document, addImageElement.outerHTML);
|
||
|
}.bind(this));
|
||
|
}
|
||
|
});
|
||
|
|
||
|
MediumEditor.extensions.fileDragging = FileDragging;
|
||
|
}());
|
||
|
|
||
|
(function () {
|
||
|
'use strict';
|
||
|
|
||
|
var KeyboardCommands = MediumEditor.Extension.extend({
|
||
|
name: 'keyboard-commands',
|
||
|
|
||
|
/* KeyboardCommands Options */
|
||
|
|
||
|
/* commands: [Array]
|
||
|
* Array of objects describing each command and the combination of keys that will trigger it
|
||
|
* Required for each object:
|
||
|
* command [String] (argument passed to editor.execAction())
|
||
|
* key [String] (keyboard character that triggers this command)
|
||
|
* meta [boolean] (whether the ctrl/meta key has to be active or inactive)
|
||
|
* shift [boolean] (whether the shift key has to be active or inactive)
|
||
|
* alt [boolean] (whether the alt key has to be active or inactive)
|
||
|
*/
|
||
|
commands: [
|
||
|
{
|
||
|
command: 'bold',
|
||
|
key: 'B',
|
||
|
meta: true,
|
||
|
shift: false,
|
||
|
alt: false
|
||
|
},
|
||
|
{
|
||
|
command: 'italic',
|
||
|
key: 'I',
|
||
|
meta: true,
|
||
|
shift: false,
|
||
|
alt: false
|
||
|
},
|
||
|
{
|
||
|
command: 'underline',
|
||
|
key: 'U',
|
||
|
meta: true,
|
||
|
shift: false,
|
||
|
alt: false
|
||
|
}
|
||
|
],
|
||
|
|
||
|
init: function () {
|
||
|
MediumEditor.Extension.prototype.init.apply(this, arguments);
|
||
|
|
||
|
this.subscribe('editableKeydown', this.handleKeydown.bind(this));
|
||
|
this.keys = {};
|
||
|
this.commands.forEach(function (command) {
|
||
|
var keyCode = command.key.charCodeAt(0);
|
||
|
if (!this.keys[keyCode]) {
|
||
|
this.keys[keyCode] = [];
|
||
|
}
|
||
|
this.keys[keyCode].push(command);
|
||
|
}, this);
|
||
|
},
|
||
|
|
||
|
handleKeydown: function (event) {
|
||
|
var keyCode = MediumEditor.util.getKeyCode(event);
|
||
|
if (!this.keys[keyCode]) {
|
||
|
return;
|
||
|
}
|
||
|
|
||
|
var isMeta = MediumEditor.util.isMetaCtrlKey(event),
|
||
|
isShift = !!event.shiftKey,
|
||
|
isAlt = !!event.altKey;
|
||
|
|
||
|
this.keys[keyCode].forEach(function (data) {
|
||
|
if (data.meta === isMeta &&
|
||
|
data.shift === isShift &&
|
||
|
(data.alt === isAlt ||
|
||
|
undefined === data.alt)) { // TODO deprecated: remove check for undefined === data.alt when jumping to 6.0.0
|
||
|
event.preventDefault();
|
||
|
event.stopPropagation();
|
||
|
|
||
|
// command can be a function to execute
|
||
|
if (typeof data.command === 'function') {
|
||
|
data.command.apply(this);
|
||
|
}
|
||
|
// command can be false so the shortcut is just disabled
|
||
|
else if (false !== data.command) {
|
||
|
this.execAction(data.command);
|
||
|
}
|
||
|
}
|
||
|
}, this);
|
||
|
}
|
||
|
});
|
||
|
|
||
|
MediumEditor.extensions.keyboardCommands = KeyboardCommands;
|
||
|
}());
|
||
|
|
||
|
(function () {
|
||
|
'use strict';
|
||
|
|
||
|
var FontNameForm = MediumEditor.extensions.form.extend({
|
||
|
|
||
|
name: 'fontname',
|
||
|
action: 'fontName',
|
||
|
aria: 'change font name',
|
||
|
contentDefault: '±', // ±
|
||
|
contentFA: '<i class="fa fa-font"></i>',
|
||
|
|
||
|
fonts: ['', 'Arial', 'Verdana', 'Times New Roman'],
|
||
|
|
||
|
init: function () {
|
||
|
MediumEditor.extensions.form.prototype.init.apply(this, arguments);
|
||
|
},
|
||
|
|
||
|
// Called when the button the toolbar is clicked
|
||
|
// Overrides ButtonExtension.handleClick
|
||
|
handleClick: function (event) {
|
||
|
event.preventDefault();
|
||
|
event.stopPropagation();
|
||
|
|
||
|
if (!this.isDisplayed()) {
|
||
|
// Get FontName of current selection (convert to string since IE returns this as number)
|
||
|
var fontName = this.document.queryCommandValue('fontName') + '';
|
||
|
this.showForm(fontName);
|
||
|
}
|
||
|
|
||
|
return false;
|
||
|
},
|
||
|
|
||
|
// Called by medium-editor to append form to the toolbar
|
||
|
getForm: function () {
|
||
|
if (!this.form) {
|
||
|
this.form = this.createForm();
|
||
|
}
|
||
|
return this.form;
|
||
|
},
|
||
|
|
||
|
// Used by medium-editor when the default toolbar is to be displayed
|
||
|
isDisplayed: function () {
|
||
|
return this.getForm().style.display === 'block';
|
||
|
},
|
||
|
|
||
|
hideForm: function () {
|
||
|
this.getForm().style.display = 'none';
|
||
|
this.getSelect().value = '';
|
||
|
},
|
||
|
|
||
|
showForm: function (fontName) {
|
||
|
var select = this.getSelect();
|
||
|
|
||
|
this.base.saveSelection();
|
||
|
this.hideToolbarDefaultActions();
|
||
|
this.getForm().style.display = 'block';
|
||
|
this.setToolbarPosition();
|
||
|
|
||
|
select.value = fontName || '';
|
||
|
select.focus();
|
||
|
},
|
||
|
|
||
|
// 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
|
||
|
|
||
|
doFormSave: function () {
|
||
|
this.base.restoreSelection();
|
||
|
this.base.checkSelection();
|
||
|
},
|
||
|
|
||
|
doFormCancel: function () {
|
||
|
this.base.restoreSelection();
|
||
|
this.clearFontName();
|
||
|
this.base.checkSelection();
|
||
|
},
|
||
|
|
||
|
// form creation and event handling
|
||
|
createForm: function () {
|
||
|
var doc = this.document,
|
||
|
form = doc.createElement('div'),
|
||
|
select = doc.createElement('select'),
|
||
|
close = doc.createElement('a'),
|
||
|
save = doc.createElement('a'),
|
||
|
option;
|
||
|
|
||
|
// Font Name Form (div)
|
||
|
form.className = 'medium-editor-toolbar-form';
|
||
|
form.id = 'medium-editor-toolbar-form-fontname-' + this.getEditorId();
|
||
|
|
||
|
// Handle clicks on the form itself
|
||
|
this.on(form, 'click', this.handleFormClick.bind(this));
|
||
|
|
||
|
// Add font names
|
||
|
for (var i = 0; i<this.fonts.length; i++) {
|
||
|
option = doc.createElement('option');
|
||
|
option.innerHTML = this.fonts[i];
|
||
|
option.value = this.fonts[i];
|
||
|
select.appendChild(option);
|
||
|
}
|
||
|
|
||
|
select.className = 'medium-editor-toolbar-select';
|
||
|
form.appendChild(select);
|
||
|
|
||
|
// Handle typing in the textbox
|
||
|
this.on(select, 'change', this.handleFontChange.bind(this));
|
||
|
|
||
|
// Add save buton
|
||
|
save.setAttribute('href', '#');
|
||
|
save.className = 'medium-editor-toobar-save';
|
||
|
save.innerHTML = this.getEditorOption('buttonLabels') === 'fontawesome' ?
|
||
|
'<i class="fa fa-check"></i>' :
|
||
|
'✓';
|
||
|
form.appendChild(save);
|
||
|
|
||
|
// Handle save button clicks (capture)
|
||
|
this.on(save, 'click', this.handleSaveClick.bind(this), true);
|
||
|
|
||
|
// Add close button
|
||
|
close.setAttribute('href', '#');
|
||
|
close.className = 'medium-editor-toobar-close';
|
||
|
close.innerHTML = this.getEditorOption('buttonLabels') === 'fontawesome' ?
|
||
|
'<i class="fa fa-times"></i>' :
|
||
|
'×';
|
||
|
form.appendChild(close);
|
||
|
|
||
|
// Handle close button clicks
|
||
|
this.on(close, 'click', this.handleCloseClick.bind(this));
|
||
|
|
||
|
return form;
|
||
|
},
|
||
|
|
||
|
getSelect: function () {
|
||
|
return this.getForm().querySelector('select.medium-editor-toolbar-select');
|
||
|
},
|
||
|
|
||
|
clearFontName: function () {
|
||
|
MediumEditor.selection.getSelectedElements(this.document).forEach(function (el) {
|
||
|
if (el.nodeName.toLowerCase() === 'font' && el.hasAttribute('face')) {
|
||
|
el.removeAttribute('face');
|
||
|
}
|
||
|
});
|
||
|
},
|
||
|
|
||
|
handleFontChange: function () {
|
||
|
var font = this.getSelect().value;
|
||
|
if (font === '') {
|
||
|
this.clearFontName();
|
||
|
} else {
|
||
|
this.execAction('fontName', { value: font });
|
||
|
}
|
||
|
},
|
||
|
|
||
|
handleFormClick: function (event) {
|
||
|
// make sure not to hide form when clicking inside the form
|
||
|
event.stopPropagation();
|
||
|
},
|
||
|
|
||
|
handleSaveClick: function (event) {
|
||
|
// Clicking Save -> create the font size
|
||
|
event.preventDefault();
|
||
|
this.doFormSave();
|
||
|
},
|
||
|
|
||
|
handleCloseClick: function (event) {
|
||
|
// Click Close -> close the form
|
||
|
event.preventDefault();
|
||
|
this.doFormCancel();
|
||
|
}
|
||
|
});
|
||
|
|
||
|
MediumEditor.extensions.fontName = FontNameForm;
|
||
|
}());
|
||
|
|
||
|
(function () {
|
||
|
'use strict';
|
||
|
|
||
|
var FontSizeForm = MediumEditor.extensions.form.extend({
|
||
|
|
||
|
name: 'fontsize',
|
||
|
action: 'fontSize',
|
||
|
aria: 'increase/decrease font size',
|
||
|
contentDefault: '±', // ±
|
||
|
contentFA: '<i class="fa fa-text-height"></i>',
|
||
|
|
||
|
init: function () {
|
||
|
MediumEditor.extensions.form.prototype.init.apply(this, arguments);
|
||
|
},
|
||
|
|
||
|
// Called when the button the toolbar is clicked
|
||
|
// Overrides ButtonExtension.handleClick
|
||
|
handleClick: function (event) {
|
||
|
event.preventDefault();
|
||
|
event.stopPropagation();
|
||
|
|
||
|
if (!this.isDisplayed()) {
|
||
|
// Get fontsize of current selection (convert to string since IE returns this as number)
|
||
|
var fontSize = this.document.queryCommandValue('fontSize') + '';
|
||
|
this.showForm(fontSize);
|
||
|
}
|
||
|
|
||
|
return false;
|
||
|
},
|
||
|
|
||
|
// Called by medium-editor to append form to the toolbar
|
||
|
getForm: function () {
|
||
|
if (!this.form) {
|
||
|
this.form = this.createForm();
|
||
|
}
|
||
|
return this.form;
|
||
|
},
|
||
|
|
||
|
// Used by medium-editor when the default toolbar is to be displayed
|
||
|
isDisplayed: function () {
|
||
|
return this.getForm().style.display === 'block';
|
||
|
},
|
||
|
|
||
|
hideForm: function () {
|
||
|
this.getForm().style.display = 'none';
|
||
|
this.getInput().value = '';
|
||
|
},
|
||
|
|
||
|
showForm: function (fontSize) {
|
||
|
var input = this.getInput();
|
||
|
|
||
|
this.base.saveSelection();
|
||
|
this.hideToolbarDefaultActions();
|
||
|
this.getForm().style.display = 'block';
|
||
|
this.setToolbarPosition();
|
||
|
|
||
|
input.value = fontSize || '';
|
||
|
input.focus();
|
||
|
},
|
||
|
|
||
|
// 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
|
||
|
|
||
|
doFormSave: function () {
|
||
|
this.base.restoreSelection();
|
||
|
this.base.checkSelection();
|
||
|
},
|
||
|
|
||
|
doFormCancel: function () {
|
||
|
this.base.restoreSelection();
|
||
|
this.clearFontSize();
|
||
|
this.base.checkSelection();
|
||
|
},
|
||
|
|
||
|
// form creation and event handling
|
||
|
createForm: function () {
|
||
|
var doc = this.document,
|
||
|
form = doc.createElement('div'),
|
||
|
input = doc.createElement('input'),
|
||
|
close = doc.createElement('a'),
|
||
|
save = doc.createElement('a');
|
||
|
|
||
|
// Font Size Form (div)
|
||
|
form.className = 'medium-editor-toolbar-form';
|
||
|
form.id = 'medium-editor-toolbar-form-fontsize-' + this.getEditorId();
|
||
|
|
||
|
// Handle clicks on the form itself
|
||
|
this.on(form, 'click', this.handleFormClick.bind(this));
|
||
|
|
||
|
// Add font size slider
|
||
|
input.setAttribute('type', 'range');
|
||
|
input.setAttribute('min', '1');
|
||
|
input.setAttribute('max', '7');
|
||
|
input.className = 'medium-editor-toolbar-input';
|
||
|
form.appendChild(input);
|
||
|
|
||
|
// Handle typing in the textbox
|
||
|
this.on(input, 'change', this.handleSliderChange.bind(this));
|
||
|
|
||
|
// Add save buton
|
||
|
save.setAttribute('href', '#');
|
||
|
save.className = 'medium-editor-toobar-save';
|
||
|
save.innerHTML = this.getEditorOption('buttonLabels') === 'fontawesome' ?
|
||
|
'<i class="fa fa-check"></i>' :
|
||
|
'✓';
|
||
|
form.appendChild(save);
|
||
|
|
||
|
// Handle save button clicks (capture)
|
||
|
this.on(save, 'click', this.handleSaveClick.bind(this), true);
|
||
|
|
||
|
// Add close button
|
||
|
close.setAttribute('href', '#');
|
||
|
close.className = 'medium-editor-toobar-close';
|
||
|
close.innerHTML = this.getEditorOption('buttonLabels') === 'fontawesome' ?
|
||
|
'<i class="fa fa-times"></i>' :
|
||
|
'×';
|
||
|
form.appendChild(close);
|
||
|
|
||
|
// Handle close button clicks
|
||
|
this.on(close, 'click', this.handleCloseClick.bind(this));
|
||
|
|
||
|
return form;
|
||
|
},
|
||
|
|
||
|
getInput: function () {
|
||
|
return this.getForm().querySelector('input.medium-editor-toolbar-input');
|
||
|
},
|
||
|
|
||
|
clearFontSize: function () {
|
||
|
MediumEditor.selection.getSelectedElements(this.document).forEach(function (el) {
|
||
|
if (el.nodeName.toLowerCase() === 'font' && el.hasAttribute('size')) {
|
||
|
el.removeAttribute('size');
|
||
|
}
|
||
|
});
|
||
|
},
|
||
|
|
||
|
handleSliderChange: function () {
|
||
|
var size = this.getInput().value;
|
||
|
if (size === '4') {
|
||
|
this.clearFontSize();
|
||
|
} else {
|
||
|
this.execAction('fontSize', { value: size });
|
||
|
}
|
||
|
},
|
||
|
|
||
|
handleFormClick: function (event) {
|
||
|
// make sure not to hide form when clicking inside the form
|
||
|
event.stopPropagation();
|
||
|
},
|
||
|
|
||
|
handleSaveClick: function (event) {
|
||
|
// Clicking Save -> create the font size
|
||
|
event.preventDefault();
|
||
|
this.doFormSave();
|
||
|
},
|
||
|
|
||
|
handleCloseClick: function (event) {
|
||
|
// Click Close -> close the form
|
||
|
event.preventDefault();
|
||
|
this.doFormCancel();
|
||
|
}
|
||
|
});
|
||
|
|
||
|
MediumEditor.extensions.fontSize = FontSizeForm;
|
||
|
}());
|
||
|
(function () {
|
||
|
'use strict';
|
||
|
|
||
|
/* Helpers and internal variables that don't need to be members of actual paste handler */
|
||
|
|
||
|
var pasteBinDefaultContent = '%ME_PASTEBIN%',
|
||
|
lastRange = null,
|
||
|
keyboardPasteEditable = null,
|
||
|
stopProp = function (event) {
|
||
|
event.stopPropagation();
|
||
|
};
|
||
|
|
||
|
/*jslint regexp: true*/
|
||
|
/*
|
||
|
jslint does not allow character negation, because the negation
|
||
|
will not match any unicode characters. In the regexes in this
|
||
|
block, negation is used specifically to match the end of an html
|
||
|
tag, and in fact unicode characters *should* be allowed.
|
||
|
*/
|
||
|
function createReplacements() {
|
||
|
return [
|
||
|
// Remove anything but the contents within the BODY element
|
||
|
[new RegExp(/^[\s\S]*<body[^>]*>\s*|\s*<\/body[^>]*>[\s\S]*$/g), ''],
|
||
|
|
||
|
// cleanup comments added by Chrome when pasting html
|
||
|
[new RegExp(/<!--StartFragment-->|<!--EndFragment-->/g), ''],
|
||
|
|
||
|
// Trailing BR elements
|
||
|
[new RegExp(/<br>$/i), ''],
|
||
|
|
||
|
// replace two bogus tags that begin pastes from google docs
|
||
|
[new RegExp(/<[^>]*docs-internal-guid[^>]*>/gi), ''],
|
||
|
[new RegExp(/<\/b>(<br[^>]*>)?$/gi), ''],
|
||
|
|
||
|
// un-html spaces and newlines inserted by OS X
|
||
|
[new RegExp(/<span class="Apple-converted-space">\s+<\/span>/g), ' '],
|
||
|
[new RegExp(/<br class="Apple-interchange-newline">/g), '<br>'],
|
||
|
|
||
|
// replace google docs italics+bold with a span to be replaced once the html is inserted
|
||
|
[new RegExp(/<span[^>]*(font-style:italic;font-weight:(bold|700)|font-weight:(bold|700);font-style:italic)[^>]*>/gi), '<span class="replace-with italic bold">'],
|
||
|
|
||
|
// replace google docs italics with a span to be replaced once the html is inserted
|
||
|
[new RegExp(/<span[^>]*font-style:italic[^>]*>/gi), '<span class="replace-with italic">'],
|
||
|
|
||
|
//[replace google docs bolds with a span to be replaced once the html is inserted
|
||
|
[new RegExp(/<span[^>]*font-weight:(bold|700)[^>]*>/gi), '<span class="replace-with bold">'],
|
||
|
|
||
|
// replace manually entered b/i/a tags with real ones
|
||
|
[new RegExp(/<(\/?)(i|b|a)>/gi), '<$1$2>'],
|
||
|
|
||
|
// replace manually a tags with real ones, converting smart-quotes from google docs
|
||
|
[new RegExp(/<a(?:(?!href).)+href=(?:"|”|“|"|“|”)(((?!"|”|“|"|“|”).)*)(?:"|”|“|"|“|”)(?:(?!>).)*>/gi), '<a href="$1">'],
|
||
|
|
||
|
// Newlines between paragraphs in html have no syntactic value,
|
||
|
// but then have a tendency to accidentally become additional paragraphs down the line
|
||
|
[new RegExp(/<\/p>\n+/gi), '</p>'],
|
||
|
[new RegExp(/\n+<p/gi), '<p'],
|
||
|
|
||
|
// Microsoft Word makes these odd tags, like <o:p></o:p>
|
||
|
[new RegExp(/<\/?o:[a-z]*>/gi), ''],
|
||
|
|
||
|
// Microsoft Word adds some special elements around list items
|
||
|
[new RegExp(/<!\[if !supportLists\]>(((?!<!).)*)<!\[endif]\>/gi), '$1']
|
||
|
];
|
||
|
}
|
||
|
/*jslint regexp: false*/
|
||
|
|
||
|
/**
|
||
|
* Gets various content types out of the Clipboard API. It will also get the
|
||
|
* plain text using older IE and WebKit API.
|
||
|
*
|
||
|
* @param {event} event Event fired on paste.
|
||
|
* @param {win} reference to window
|
||
|
* @param {doc} reference to document
|
||
|
* @return {Object} Object with mime types and data for those mime types.
|
||
|
*/
|
||
|
function getClipboardContent(event, win, doc) {
|
||
|
var dataTransfer = event.clipboardData || win.clipboardData || doc.dataTransfer,
|
||
|
data = {};
|
||
|
|
||
|
if (!dataTransfer) {
|
||
|
return data;
|
||
|
}
|
||
|
|
||
|
// Use old WebKit/IE API
|
||
|
if (dataTransfer.getData) {
|
||
|
var legacyText = dataTransfer.getData('Text');
|
||
|
if (legacyText && legacyText.length > 0) {
|
||
|
data['text/plain'] = legacyText;
|
||
|
}
|
||
|
}
|
||
|
|
||
|
if (dataTransfer.types) {
|
||
|
for (var i = 0; i < dataTransfer.types.length; i++) {
|
||
|
var contentType = dataTransfer.types[i];
|
||
|
data[contentType] = dataTransfer.getData(contentType);
|
||
|
}
|
||
|
}
|
||
|
|
||
|
return data;
|
||
|
}
|
||
|
|
||
|
var PasteHandler = MediumEditor.Extension.extend({
|
||
|
/* Paste Options */
|
||
|
|
||
|
/* forcePlainText: [boolean]
|
||
|
* Forces pasting as plain text.
|
||
|
*/
|
||
|
forcePlainText: true,
|
||
|
|
||
|
/* cleanPastedHTML: [boolean]
|
||
|
* cleans pasted content from different sources, like google docs etc.
|
||
|
*/
|
||
|
cleanPastedHTML: false,
|
||
|
|
||
|
/* preCleanReplacements: [Array]
|
||
|
* custom pairs (2 element arrays) of RegExp and replacement text to use during past when
|
||
|
* __forcePlainText__ or __cleanPastedHTML__ are `true` OR when calling `cleanPaste(text)` helper method.
|
||
|
* These replacements are executed before any medium editor defined replacements.
|
||
|
*/
|
||
|
preCleanReplacements: [],
|
||
|
|
||
|
/* cleanReplacements: [Array]
|
||
|
* custom pairs (2 element arrays) of RegExp and replacement text to use during paste when
|
||
|
* __forcePlainText__ or __cleanPastedHTML__ are `true` OR when calling `cleanPaste(text)` helper method.
|
||
|
* These replacements are executed after any medium editor defined replacements.
|
||
|
*/
|
||
|
cleanReplacements: [],
|
||
|
|
||
|
/* cleanAttrs:: [Array]
|
||
|
* list of element attributes to remove during paste when __cleanPastedHTML__ is `true` or when
|
||
|
* calling `cleanPaste(text)` or `pasteHTML(html, options)` helper methods.
|
||
|
*/
|
||
|
cleanAttrs: ['class', 'style', 'dir'],
|
||
|
|
||
|
/* cleanTags: [Array]
|
||
|
* list of element tag names to remove during paste when __cleanPastedHTML__ is `true` or when
|
||
|
* calling `cleanPaste(text)` or `pasteHTML(html, options)` helper methods.
|
||
|
*/
|
||
|
cleanTags: ['meta'],
|
||
|
|
||
|
/* unwrapTags: [Array]
|
||
|
* list of element tag names to unwrap (remove the element tag but retain its child elements)
|
||
|
* during paste when __cleanPastedHTML__ is `true` or when
|
||
|
* calling `cleanPaste(text)` or `pasteHTML(html, options)` helper methods.
|
||
|
*/
|
||
|
unwrapTags: [],
|
||
|
|
||
|
init: function () {
|
||
|
MediumEditor.Extension.prototype.init.apply(this, arguments);
|
||
|
|
||
|
if (this.forcePlainText || this.cleanPastedHTML) {
|
||
|
this.subscribe('editableKeydown', this.handleKeydown.bind(this));
|
||
|
// We need access to the full event data in paste
|
||
|
// so we can't use the editablePaste event here
|
||
|
this.getEditorElements().forEach(function (element) {
|
||
|
this.on(element, 'paste', this.handlePaste.bind(this));
|
||
|
}, this);
|
||
|
this.subscribe('addElement', this.handleAddElement.bind(this));
|
||
|
}
|
||
|
},
|
||
|
|
||
|
handleAddElement: function (event, editable) {
|
||
|
this.on(editable, 'paste', this.handlePaste.bind(this));
|
||
|
},
|
||
|
|
||
|
destroy: function () {
|
||
|
// Make sure pastebin is destroyed in case it's still around for some reason
|
||
|
if (this.forcePlainText || this.cleanPastedHTML) {
|
||
|
this.removePasteBin();
|
||
|
}
|
||
|
},
|
||
|
|
||
|
handlePaste: function (event, editable) {
|
||
|
if (event.defaultPrevented) {
|
||
|
return;
|
||
|
}
|
||
|
|
||
|
var clipboardContent = getClipboardContent(event, this.window, this.document),
|
||
|
pastedHTML = clipboardContent['text/html'],
|
||
|
pastedPlain = clipboardContent['text/plain'];
|
||
|
|
||
|
if (this.window.clipboardData && event.clipboardData === undefined && !pastedHTML) {
|
||
|
// If window.clipboardData exists, but event.clipboardData doesn't exist,
|
||
|
// we're probably in IE. IE only has two possibilities for clipboard
|
||
|
// data format: 'Text' and 'URL'.
|
||
|
//
|
||
|
// For IE, we'll fallback to 'Text' for text/html
|
||
|
pastedHTML = pastedPlain;
|
||
|
}
|
||
|
|
||
|
if (pastedHTML || pastedPlain) {
|
||
|
event.preventDefault();
|
||
|
|
||
|
this.doPaste(pastedHTML, pastedPlain, editable);
|
||
|
}
|
||
|
},
|
||
|
|
||
|
doPaste: function (pastedHTML, pastedPlain, editable) {
|
||
|
var paragraphs,
|
||
|
html = '',
|
||
|
p;
|
||
|
|
||
|
if (this.cleanPastedHTML && pastedHTML) {
|
||
|
return this.cleanPaste(pastedHTML);
|
||
|
}
|
||
|
|
||
|
if (!pastedPlain) {
|
||
|
return;
|
||
|
}
|
||
|
|
||
|
if (!(this.getEditorOption('disableReturn') || (editable && editable.getAttribute('data-disable-return')))) {
|
||
|
paragraphs = pastedPlain.split(/[\r\n]+/g);
|
||
|
// If there are no \r\n in data, don't wrap in <p>
|
||
|
if (paragraphs.length > 1) {
|
||
|
for (p = 0; p < paragraphs.length; p += 1) {
|
||
|
if (paragraphs[p] !== '') {
|
||
|
html += '<p>' + MediumEditor.util.htmlEntities(paragraphs[p]) + '</p>';
|
||
|
}
|
||
|
}
|
||
|
} else {
|
||
|
html = MediumEditor.util.htmlEntities(paragraphs[0]);
|
||
|
}
|
||
|
} else {
|
||
|
html = MediumEditor.util.htmlEntities(pastedPlain);
|
||
|
}
|
||
|
MediumEditor.util.insertHTMLCommand(this.document, html);
|
||
|
},
|
||
|
|
||
|
handlePasteBinPaste: function (event) {
|
||
|
if (event.defaultPrevented) {
|
||
|
this.removePasteBin();
|
||
|
return;
|
||
|
}
|
||
|
|
||
|
var clipboardContent = getClipboardContent(event, this.window, this.document),
|
||
|
pastedHTML = clipboardContent['text/html'],
|
||
|
pastedPlain = clipboardContent['text/plain'],
|
||
|
editable = keyboardPasteEditable;
|
||
|
|
||
|
// If we have valid html already, or we're not in cleanPastedHTML mode
|
||
|
// we can ignore the paste bin and just paste now
|
||
|
if (!this.cleanPastedHTML || pastedHTML) {
|
||
|
event.preventDefault();
|
||
|
this.removePasteBin();
|
||
|
this.doPaste(pastedHTML, pastedPlain, editable);
|
||
|
|
||
|
// The event handling code listens for paste on the editable element
|
||
|
// in order to trigger the editablePaste event. Since this paste event
|
||
|
// is happening on the pastebin, the event handling code never knows about it
|
||
|
// So, we have to trigger editablePaste manually
|
||
|
this.trigger('editablePaste', { currentTarget: editable, target: editable }, editable);
|
||
|
return;
|
||
|
}
|
||
|
|
||
|
// We need to look at the paste bin, so do a setTimeout to let the paste
|
||
|
// fall through into the paste bin
|
||
|
setTimeout(function () {
|
||
|
// Only look for HTML if we're in cleanPastedHTML mode
|
||
|
if (this.cleanPastedHTML) {
|
||
|
// If clipboard didn't have HTML, try the paste bin
|
||
|
pastedHTML = this.getPasteBinHtml();
|
||
|
}
|
||
|
|
||
|
// If we needed the paste bin, we're done with it now, remove it
|
||
|
this.removePasteBin();
|
||
|
|
||
|
// Handle the paste with the html from the paste bin
|
||
|
this.doPaste(pastedHTML, pastedPlain, editable);
|
||
|
|
||
|
// The event handling code listens for paste on the editable element
|
||
|
// in order to trigger the editablePaste event. Since this paste event
|
||
|
// is happening on the pastebin, the event handling code never knows about it
|
||
|
// So, we have to trigger editablePaste manually
|
||
|
this.trigger('editablePaste', { currentTarget: editable, target: editable }, editable);
|
||
|
}.bind(this), 0);
|
||
|
},
|
||
|
|
||
|
handleKeydown: function (event, editable) {
|
||
|
// if it's not Ctrl+V, do nothing
|
||
|
if (!(MediumEditor.util.isKey(event, MediumEditor.util.keyCode.V) && MediumEditor.util.isMetaCtrlKey(event))) {
|
||
|
return;
|
||
|
}
|
||
|
|
||
|
event.stopImmediatePropagation();
|
||
|
|
||
|
this.removePasteBin();
|
||
|
this.createPasteBin(editable);
|
||
|
},
|
||
|
|
||
|
createPasteBin: function (editable) {
|
||
|
var rects,
|
||
|
range = MediumEditor.selection.getSelectionRange(this.document),
|
||
|
top = this.window.pageYOffset;
|
||
|
|
||
|
keyboardPasteEditable = editable;
|
||
|
|
||
|
if (range) {
|
||
|
rects = range.getClientRects();
|
||
|
|
||
|
// on empty line, rects is empty so we grab information from the first container of the range
|
||
|
if (rects.length) {
|
||
|
top += rects[0].top;
|
||
|
} else if (range.startContainer.getBoundingClientRect !== undefined) {
|
||
|
top += range.startContainer.getBoundingClientRect().top;
|
||
|
} else {
|
||
|
top += range.getBoundingClientRect().top;
|
||
|
}
|
||
|
}
|
||
|
|
||
|
lastRange = range;
|
||
|
|
||
|
var pasteBinElm = this.document.createElement('div');
|
||
|
pasteBinElm.id = this.pasteBinId = 'medium-editor-pastebin-' + (+Date.now());
|
||
|
pasteBinElm.setAttribute('style', 'border: 1px red solid; position: absolute; top: ' + top + 'px; width: 10px; height: 10px; overflow: hidden; opacity: 0');
|
||
|
pasteBinElm.setAttribute('contentEditable', true);
|
||
|
pasteBinElm.innerHTML = pasteBinDefaultContent;
|
||
|
|
||
|
this.document.body.appendChild(pasteBinElm);
|
||
|
|
||
|
// avoid .focus() to stop other event (actually the paste event)
|
||
|
this.on(pasteBinElm, 'focus', stopProp);
|
||
|
this.on(pasteBinElm, 'focusin', stopProp);
|
||
|
this.on(pasteBinElm, 'focusout', stopProp);
|
||
|
|
||
|
pasteBinElm.focus();
|
||
|
|
||
|
MediumEditor.selection.selectNode(pasteBinElm, this.document);
|
||
|
|
||
|
if (!this.boundHandlePaste) {
|
||
|
this.boundHandlePaste = this.handlePasteBinPaste.bind(this);
|
||
|
}
|
||
|
|
||
|
this.on(pasteBinElm, 'paste', this.boundHandlePaste);
|
||
|
},
|
||
|
|
||
|
removePasteBin: function () {
|
||
|
if (null !== lastRange) {
|
||
|
MediumEditor.selection.selectRange(this.document, lastRange);
|
||
|
lastRange = null;
|
||
|
}
|
||
|
|
||
|
if (null !== keyboardPasteEditable) {
|
||
|
keyboardPasteEditable = null;
|
||
|
}
|
||
|
|
||
|
var pasteBinElm = this.getPasteBin();
|
||
|
if (!pasteBinElm) {
|
||
|
return;
|
||
|
}
|
||
|
|
||
|
if (pasteBinElm) {
|
||
|
this.off(pasteBinElm, 'focus', stopProp);
|
||
|
this.off(pasteBinElm, 'focusin', stopProp);
|
||
|
this.off(pasteBinElm, 'focusout', stopProp);
|
||
|
this.off(pasteBinElm, 'paste', this.boundHandlePaste);
|
||
|
pasteBinElm.parentElement.removeChild(pasteBinElm);
|
||
|
}
|
||
|
},
|
||
|
|
||
|
getPasteBin: function () {
|
||
|
return this.document.getElementById(this.pasteBinId);
|
||
|
},
|
||
|
|
||
|
getPasteBinHtml: function () {
|
||
|
var pasteBinElm = this.getPasteBin();
|
||
|
|
||
|
if (!pasteBinElm) {
|
||
|
return false;
|
||
|
}
|
||
|
|
||
|
// WebKit has a nice bug where it clones the paste bin if you paste from for example notepad
|
||
|
// so we need to force plain text mode in this case
|
||
|
if (pasteBinElm.firstChild && pasteBinElm.firstChild.id === 'mcepastebin') {
|
||
|
return false;
|
||
|
}
|
||
|
|
||
|
var pasteBinHtml = pasteBinElm.innerHTML;
|
||
|
|
||
|
// If paste bin is empty try using plain text mode
|
||
|
// since that is better than nothing right
|
||
|
if (!pasteBinHtml || pasteBinHtml === pasteBinDefaultContent) {
|
||
|
return false;
|
||
|
}
|
||
|
|
||
|
return pasteBinHtml;
|
||
|
},
|
||
|
|
||
|
cleanPaste: function (text) {
|
||
|
var i, elList, tmp, workEl,
|
||
|
multiline = /<p|<br|<div/.test(text),
|
||
|
replacements = [].concat(
|
||
|
this.preCleanReplacements || [],
|
||
|
createReplacements(),
|
||
|
this.cleanReplacements || []);
|
||
|
|
||
|
for (i = 0; i < replacements.length; i += 1) {
|
||
|
text = text.replace(replacements[i][0], replacements[i][1]);
|
||
|
}
|
||
|
|
||
|
if (!multiline) {
|
||
|
return this.pasteHTML(text);
|
||
|
}
|
||
|
|
||
|
// create a temporary div to cleanup block elements
|
||
|
tmp = this.document.createElement('div');
|
||
|
|
||
|
// double br's aren't converted to p tags, but we want paragraphs.
|
||
|
tmp.innerHTML = '<p>' + text.split('<br><br>').join('</p><p>') + '</p>';
|
||
|
|
||
|
// block element cleanup
|
||
|
elList = tmp.querySelectorAll('a,p,div,br');
|
||
|
for (i = 0; i < elList.length; i += 1) {
|
||
|
workEl = elList[i];
|
||
|
|
||
|
// Microsoft Word replaces some spaces with newlines.
|
||
|
// While newlines between block elements are meaningless, newlines within
|
||
|
// elements are sometimes actually spaces.
|
||
|
workEl.innerHTML = workEl.innerHTML.replace(/\n/gi, ' ');
|
||
|
|
||
|
switch (workEl.nodeName.toLowerCase()) {
|
||
|
case 'p':
|
||
|
case 'div':
|
||
|
this.filterCommonBlocks(workEl);
|
||
|
break;
|
||
|
case 'br':
|
||
|
this.filterLineBreak(workEl);
|
||
|
break;
|
||
|
}
|
||
|
}
|
||
|
|
||
|
this.pasteHTML(tmp.innerHTML);
|
||
|
},
|
||
|
|
||
|
pasteHTML: function (html, options) {
|
||
|
options = MediumEditor.util.defaults({}, options, {
|
||
|
cleanAttrs: this.cleanAttrs,
|
||
|
cleanTags: this.cleanTags,
|
||
|
unwrapTags: this.unwrapTags
|
||
|
});
|
||
|
|
||
|
var elList, workEl, i, fragmentBody, pasteBlock = this.document.createDocumentFragment();
|
||
|
|
||
|
pasteBlock.appendChild(this.document.createElement('body'));
|
||
|
|
||
|
fragmentBody = pasteBlock.querySelector('body');
|
||
|
fragmentBody.innerHTML = html;
|
||
|
|
||
|
this.cleanupSpans(fragmentBody);
|
||
|
|
||
|
elList = fragmentBody.querySelectorAll('*');
|
||
|
for (i = 0; i < elList.length; i += 1) {
|
||
|
workEl = elList[i];
|
||
|
|
||
|
if ('a' === workEl.nodeName.toLowerCase() && this.getEditorOption('targetBlank')) {
|
||
|
MediumEditor.util.setTargetBlank(workEl);
|
||
|
}
|
||
|
|
||
|
MediumEditor.util.cleanupAttrs(workEl, options.cleanAttrs);
|
||
|
MediumEditor.util.cleanupTags(workEl, options.cleanTags);
|
||
|
MediumEditor.util.unwrapTags(workEl, options.unwrapTags);
|
||
|
}
|
||
|
|
||
|
MediumEditor.util.insertHTMLCommand(this.document, fragmentBody.innerHTML.replace(/ /g, ' '));
|
||
|
},
|
||
|
|
||
|
// TODO (6.0): Make this an internal helper instead of member of paste handler
|
||
|
isCommonBlock: function (el) {
|
||
|
return (el && (el.nodeName.toLowerCase() === 'p' || el.nodeName.toLowerCase() === 'div'));
|
||
|
},
|
||
|
|
||
|
// TODO (6.0): Make this an internal helper instead of member of paste handler
|
||
|
filterCommonBlocks: function (el) {
|
||
|
if (/^\s*$/.test(el.textContent) && el.parentNode) {
|
||
|
el.parentNode.removeChild(el);
|
||
|
}
|
||
|
},
|
||
|
|
||
|
// TODO (6.0): Make this an internal helper instead of member of paste handler
|
||
|
filterLineBreak: function (el) {
|
||
|
if (this.isCommonBlock(el.previousElementSibling)) {
|
||
|
// remove stray br's following common block elements
|
||
|
this.removeWithParent(el);
|
||
|
} else if (this.isCommonBlock(el.parentNode) && (el.parentNode.firstChild === el || el.parentNode.lastChild === el)) {
|
||
|
// remove br's just inside open or close tags of a div/p
|
||
|
this.removeWithParent(el);
|
||
|
} else if (el.parentNode && el.parentNode.childElementCount === 1 && el.parentNode.textContent === '') {
|
||
|
// and br's that are the only child of elements other than div/p
|
||
|
this.removeWithParent(el);
|
||
|
}
|
||
|
},
|
||
|
|
||
|
// TODO (6.0): Make this an internal helper instead of member of paste handler
|
||
|
// remove an element, including its parent, if it is the only element within its parent
|
||
|
removeWithParent: function (el) {
|
||
|
if (el && el.parentNode) {
|
||
|
if (el.parentNode.parentNode && el.parentNode.childElementCount === 1) {
|
||
|
el.parentNode.parentNode.removeChild(el.parentNode);
|
||
|
} else {
|
||
|
el.parentNode.removeChild(el);
|
||
|
}
|
||
|
}
|
||
|
},
|
||
|
|
||
|
// TODO (6.0): Make this an internal helper instead of member of paste handler
|
||
|
cleanupSpans: function (containerEl) {
|
||
|
var i,
|
||
|
el,
|
||
|
newEl,
|
||
|
spans = containerEl.querySelectorAll('.replace-with'),
|
||
|
isCEF = function (el) {
|
||
|
return (el && el.nodeName !== '#text' && el.getAttribute('contenteditable') === 'false');
|
||
|
};
|
||
|
|
||
|
for (i = 0; i < spans.length; i += 1) {
|
||
|
el = spans[i];
|
||
|
newEl = this.document.createElement(el.classList.contains('bold') ? 'b' : 'i');
|
||
|
|
||
|
if (el.classList.contains('bold') && el.classList.contains('italic')) {
|
||
|
// add an i tag as well if this has both italics and bold
|
||
|
newEl.innerHTML = '<i>' + el.innerHTML + '</i>';
|
||
|
} else {
|
||
|
newEl.innerHTML = el.innerHTML;
|
||
|
}
|
||
|
el.parentNode.replaceChild(newEl, el);
|
||
|
}
|
||
|
|
||
|
spans = containerEl.querySelectorAll('span');
|
||
|
for (i = 0; i < spans.length; i += 1) {
|
||
|
el = spans[i];
|
||
|
|
||
|
// bail if span is in contenteditable = false
|
||
|
if (MediumEditor.util.traverseUp(el, isCEF)) {
|
||
|
return false;
|
||
|
}
|
||
|
|
||
|
// remove empty spans, replace others with their contents
|
||
|
MediumEditor.util.unwrap(el, this.document);
|
||
|
}
|
||
|
}
|
||
|
});
|
||
|
|
||
|
MediumEditor.extensions.paste = PasteHandler;
|
||
|
}());
|
||
|
|
||
|
(function () {
|
||
|
'use strict';
|
||
|
|
||
|
var Placeholder = MediumEditor.Extension.extend({
|
||
|
name: 'placeholder',
|
||
|
|
||
|
/* Placeholder Options */
|
||
|
|
||
|
/* text: [string]
|
||
|
* Text to display in the placeholder
|
||
|
*/
|
||
|
text: 'Type your text',
|
||
|
|
||
|
/* hideOnClick: [boolean]
|
||
|
* Should we hide the placeholder on click (true) or when user starts typing (false)
|
||
|
*/
|
||
|
hideOnClick: true,
|
||
|
|
||
|
init: function () {
|
||
|
MediumEditor.Extension.prototype.init.apply(this, arguments);
|
||
|
|
||
|
this.initPlaceholders();
|
||
|
this.attachEventHandlers();
|
||
|
},
|
||
|
|
||
|
initPlaceholders: function () {
|
||
|
this.getEditorElements().forEach(this.initElement, this);
|
||
|
},
|
||
|
|
||
|
handleAddElement: function (event, editable) {
|
||
|
this.initElement(editable);
|
||
|
},
|
||
|
|
||
|
initElement: function (el) {
|
||
|
if (!el.getAttribute('data-placeholder')) {
|
||
|
el.setAttribute('data-placeholder', this.text);
|
||
|
}
|
||
|
this.updatePlaceholder(el);
|
||
|
},
|
||
|
|
||
|
destroy: function () {
|
||
|
this.getEditorElements().forEach(this.cleanupElement, this);
|
||
|
},
|
||
|
|
||
|
handleRemoveElement: function (event, editable) {
|
||
|
this.cleanupElement(editable);
|
||
|
},
|
||
|
|
||
|
cleanupElement: function (el) {
|
||
|
if (el.getAttribute('data-placeholder') === this.text) {
|
||
|
el.removeAttribute('data-placeholder');
|
||
|
}
|
||
|
},
|
||
|
|
||
|
showPlaceholder: function (el) {
|
||
|
if (el) {
|
||
|
// https://github.com/yabwe/medium-editor/issues/234
|
||
|
// In firefox, styling the placeholder with an absolutely positioned
|
||
|
// pseudo element causes the cursor to appear in a bad location
|
||
|
// when the element is completely empty, so apply a different class to
|
||
|
// style it with a relatively positioned pseudo element
|
||
|
if (MediumEditor.util.isFF && el.childNodes.length === 0) {
|
||
|
el.classList.add('medium-editor-placeholder-relative');
|
||
|
el.classList.remove('medium-editor-placeholder');
|
||
|
} else {
|
||
|
el.classList.add('medium-editor-placeholder');
|
||
|
el.classList.remove('medium-editor-placeholder-relative');
|
||
|
}
|
||
|
}
|
||
|
},
|
||
|
|
||
|
hidePlaceholder: function (el) {
|
||
|
if (el) {
|
||
|
el.classList.remove('medium-editor-placeholder');
|
||
|
el.classList.remove('medium-editor-placeholder-relative');
|
||
|
}
|
||
|
},
|
||
|
|
||
|
updatePlaceholder: function (el, dontShow) {
|
||
|
// If the element has content, hide the placeholder
|
||
|
if (el.querySelector('img, blockquote, ul, ol, table') || (el.textContent.replace(/^\s+|\s+$/g, '') !== '')) {
|
||
|
return this.hidePlaceholder(el);
|
||
|
}
|
||
|
|
||
|
if (!dontShow) {
|
||
|
this.showPlaceholder(el);
|
||
|
}
|
||
|
},
|
||
|
|
||
|
attachEventHandlers: function () {
|
||
|
if (this.hideOnClick) {
|
||
|
// For the 'hideOnClick' option, the placeholder should always be hidden on focus
|
||
|
this.subscribe('focus', this.handleFocus.bind(this));
|
||
|
}
|
||
|
|
||
|
// If the editor has content, it should always hide the placeholder
|
||
|
this.subscribe('editableInput', this.handleInput.bind(this));
|
||
|
|
||
|
// When the editor loses focus, check if the placeholder should be visible
|
||
|
this.subscribe('blur', this.handleBlur.bind(this));
|
||
|
|
||
|
// Need to know when elements are added/removed from the editor
|
||
|
this.subscribe('addElement', this.handleAddElement.bind(this));
|
||
|
this.subscribe('removeElement', this.handleRemoveElement.bind(this));
|
||
|
},
|
||
|
|
||
|
handleInput: function (event, element) {
|
||
|
// If the placeholder should be hidden on focus and the
|
||
|
// element has focus, don't show the placeholder
|
||
|
var dontShow = this.hideOnClick && (element === this.base.getFocusedElement());
|
||
|
|
||
|
// Editor's content has changed, check if the placeholder should be hidden
|
||
|
this.updatePlaceholder(element, dontShow);
|
||
|
},
|
||
|
|
||
|
handleFocus: function (event, element) {
|
||
|
// Editor has focus, hide the placeholder
|
||
|
this.hidePlaceholder(element);
|
||
|
},
|
||
|
|
||
|
handleBlur: function (event, element) {
|
||
|
// Editor has lost focus, check if the placeholder should be shown
|
||
|
this.updatePlaceholder(element);
|
||
|
}
|
||
|
});
|
||
|
|
||
|
MediumEditor.extensions.placeholder = Placeholder;
|
||
|
}());
|
||
|
|
||
|
(function () {
|
||
|
'use strict';
|
||
|
|
||
|
var Toolbar = MediumEditor.Extension.extend({
|
||
|
name: 'toolbar',
|
||
|
|
||
|
/* Toolbar Options */
|
||
|
|
||
|
/* align: ['left'|'center'|'right']
|
||
|
* When the __static__ option is true, this aligns the static toolbar
|
||
|
* relative to the medium-editor element.
|
||
|
*/
|
||
|
align: 'center',
|
||
|
|
||
|
/* allowMultiParagraphSelection: [boolean]
|
||
|
* enables/disables whether the toolbar should be displayed when
|
||
|
* selecting multiple paragraphs/block elements
|
||
|
*/
|
||
|
allowMultiParagraphSelection: true,
|
||
|
|
||
|
/* buttons: [Array]
|
||
|
* the names of the set of buttons to display on the toolbar.
|
||
|
*/
|
||
|
buttons: ['bold', 'italic', 'underline', 'anchor', 'h2', 'h3', 'quote'],
|
||
|
|
||
|
/* diffLeft: [Number]
|
||
|
* value in pixels to be added to the X axis positioning of the toolbar.
|
||
|
*/
|
||
|
diffLeft: 0,
|
||
|
|
||
|
/* diffTop: [Number]
|
||
|
* value in pixels to be added to the Y axis positioning of the toolbar.
|
||
|
*/
|
||
|
diffTop: -10,
|
||
|
|
||
|
/* firstButtonClass: [string]
|
||
|
* CSS class added to the first button in the toolbar.
|
||
|
*/
|
||
|
firstButtonClass: 'medium-editor-button-first',
|
||
|
|
||
|
/* lastButtonClass: [string]
|
||
|
* CSS class added to the last button in the toolbar.
|
||
|
*/
|
||
|
lastButtonClass: 'medium-editor-button-last',
|
||
|
|
||
|
/* standardizeSelectionStart: [boolean]
|
||
|
* enables/disables standardizing how the beginning of a range is decided
|
||
|
* between browsers whenever the selected text is analyzed for updating toolbar buttons status.
|
||
|
*/
|
||
|
standardizeSelectionStart: false,
|
||
|
|
||
|
/* static: [boolean]
|
||
|
* enable/disable the toolbar always displaying in the same location
|
||
|
* relative to the medium-editor element.
|
||
|
*/
|
||
|
static: false,
|
||
|
|
||
|
/* sticky: [boolean]
|
||
|
* When the __static__ option is true, this enables/disables the toolbar
|
||
|
* "sticking" to the viewport and staying visible on the screen while
|
||
|
* the page scrolls.
|
||
|
*/
|
||
|
sticky: false,
|
||
|
|
||
|
/* stickyTopOffset: [Number]
|
||
|
* Value in pixel of the top offset above the toolbar
|
||
|
*/
|
||
|
stickyTopOffset: 0,
|
||
|
|
||
|
/* updateOnEmptySelection: [boolean]
|
||
|
* When the __static__ option is true, this enables/disables updating
|
||
|
* the state of the toolbar buttons even when the selection is collapsed
|
||
|
* (there is no selection, just a cursor).
|
||
|
*/
|
||
|
updateOnEmptySelection: false,
|
||
|
|
||
|
/* relativeContainer: [node]
|
||
|
* appending the toolbar to a given node instead of body
|
||
|
*/
|
||
|
relativeContainer: null,
|
||
|
|
||
|
init: function () {
|
||
|
MediumEditor.Extension.prototype.init.apply(this, arguments);
|
||
|
|
||
|
this.initThrottledMethods();
|
||
|
|
||
|
if (!this.relativeContainer) {
|
||
|
this.getEditorOption('elementsContainer').appendChild(this.getToolbarElement());
|
||
|
} else {
|
||
|
this.relativeContainer.appendChild(this.getToolbarElement());
|
||
|
}
|
||
|
},
|
||
|
|
||
|
// Helper method to execute method for every extension, but ignoring the toolbar extension
|
||
|
forEachExtension: function (iterator, context) {
|
||
|
return this.base.extensions.forEach(function (command) {
|
||
|
if (command === this) {
|
||
|
return;
|
||
|
}
|
||
|
return iterator.apply(context || this, arguments);
|
||
|
}, this);
|
||
|
},
|
||
|
|
||
|
// Toolbar creation/deletion
|
||
|
|
||
|
createToolbar: function () {
|
||
|
var toolbar = this.document.createElement('div');
|
||
|
|
||
|
toolbar.id = 'medium-editor-toolbar-' + this.getEditorId();
|
||
|
toolbar.className = 'medium-editor-toolbar';
|
||
|
|
||
|
if (this.static) {
|
||
|
toolbar.className += ' static-toolbar';
|
||
|
} else if (this.relativeContainer) {
|
||
|
toolbar.className += ' medium-editor-relative-toolbar';
|
||
|
} else {
|
||
|
toolbar.className += ' medium-editor-stalker-toolbar';
|
||
|
}
|
||
|
|
||
|
toolbar.appendChild(this.createToolbarButtons());
|
||
|
|
||
|
// Add any forms that extensions may have
|
||
|
this.forEachExtension(function (extension) {
|
||
|
if (extension.hasForm) {
|
||
|
toolbar.appendChild(extension.getForm());
|
||
|
}
|
||
|
});
|
||
|
|
||
|
this.attachEventHandlers();
|
||
|
|
||
|
return toolbar;
|
||
|
},
|
||
|
|
||
|
createToolbarButtons: function () {
|
||
|
var ul = this.document.createElement('ul'),
|
||
|
li,
|
||
|
btn,
|
||
|
buttons,
|
||
|
extension,
|
||
|
buttonName,
|
||
|
buttonOpts;
|
||
|
|
||
|
ul.id = 'medium-editor-toolbar-actions' + this.getEditorId();
|
||
|
ul.className = 'medium-editor-toolbar-actions';
|
||
|
ul.style.display = 'block';
|
||
|
|
||
|
this.buttons.forEach(function (button) {
|
||
|
if (typeof button === 'string') {
|
||
|
buttonName = button;
|
||
|
buttonOpts = null;
|
||
|
} else {
|
||
|
buttonName = button.name;
|
||
|
buttonOpts = button;
|
||
|
}
|
||
|
|
||
|
// If the button already exists as an extension, it'll be returned
|
||
|
// othwerise it'll create the default built-in button
|
||
|
extension = this.base.addBuiltInExtension(buttonName, buttonOpts);
|
||
|
|
||
|
if (extension && typeof extension.getButton === 'function') {
|
||
|
btn = extension.getButton(this.base);
|
||
|
li = this.document.createElement('li');
|
||
|
if (MediumEditor.util.isElement(btn)) {
|
||
|
li.appendChild(btn);
|
||
|
} else {
|
||
|
li.innerHTML = btn;
|
||
|
}
|
||
|
ul.appendChild(li);
|
||
|
}
|
||
|
}, this);
|
||
|
|
||
|
buttons = ul.querySelectorAll('button');
|
||
|
if (buttons.length > 0) {
|
||
|
buttons[0].classList.add(this.firstButtonClass);
|
||
|
buttons[buttons.length - 1].classList.add(this.lastButtonClass);
|
||
|
}
|
||
|
|
||
|
return ul;
|
||
|
},
|
||
|
|
||
|
destroy: function () {
|
||
|
if (this.toolbar) {
|
||
|
if (this.toolbar.parentNode) {
|
||
|
this.toolbar.parentNode.removeChild(this.toolbar);
|
||
|
}
|
||
|
delete this.toolbar;
|
||
|
}
|
||
|
},
|
||
|
|
||
|
// Toolbar accessors
|
||
|
|
||
|
getInteractionElements: function () {
|
||
|
return this.getToolbarElement();
|
||
|
},
|
||
|
|
||
|
getToolbarElement: function () {
|
||
|
if (!this.toolbar) {
|
||
|
this.toolbar = this.createToolbar();
|
||
|
}
|
||
|
|
||
|
return this.toolbar;
|
||
|
},
|
||
|
|
||
|
getToolbarActionsElement: function () {
|
||
|
return this.getToolbarElement().querySelector('.medium-editor-toolbar-actions');
|
||
|
},
|
||
|
|
||
|
// Toolbar event handlers
|
||
|
|
||
|
initThrottledMethods: function () {
|
||
|
// throttledPositionToolbar is throttled because:
|
||
|
// - It will be called when the browser is resizing, which can fire many times very quickly
|
||
|
// - For some event (like resize) a slight lag in UI responsiveness is OK and provides performance benefits
|
||
|
this.throttledPositionToolbar = MediumEditor.util.throttle(function () {
|
||
|
if (this.base.isActive) {
|
||
|
this.positionToolbarIfShown();
|
||
|
}
|
||
|
}.bind(this));
|
||
|
},
|
||
|
|
||
|
attachEventHandlers: function () {
|
||
|
// MediumEditor custom events for when user beings and ends interaction with a contenteditable and its elements
|
||
|
this.subscribe('blur', this.handleBlur.bind(this));
|
||
|
this.subscribe('focus', this.handleFocus.bind(this));
|
||
|
|
||
|
// Updating the state of the toolbar as things change
|
||
|
this.subscribe('editableClick', this.handleEditableClick.bind(this));
|
||
|
this.subscribe('editableKeyup', this.handleEditableKeyup.bind(this));
|
||
|
|
||
|
// Handle mouseup on document for updating the selection in the toolbar
|
||
|
this.on(this.document.documentElement, 'mouseup', this.handleDocumentMouseup.bind(this));
|
||
|
|
||
|
// Add a scroll event for sticky toolbar
|
||
|
if (this.static && this.sticky) {
|
||
|
// On scroll (capture), re-position the toolbar
|
||
|
this.on(this.window, 'scroll', this.handleWindowScroll.bind(this), true);
|
||
|
}
|
||
|
|
||
|
// On resize, re-position the toolbar
|
||
|
this.on(this.window, 'resize', this.handleWindowResize.bind(this));
|
||
|
},
|
||
|
|
||
|
handleWindowScroll: function () {
|
||
|
this.positionToolbarIfShown();
|
||
|
},
|
||
|
|
||
|
handleWindowResize: function () {
|
||
|
this.throttledPositionToolbar();
|
||
|
},
|
||
|
|
||
|
handleDocumentMouseup: function (event) {
|
||
|
// Do not trigger checkState when mouseup fires over the toolbar
|
||
|
if (event &&
|
||
|
event.target &&
|
||
|
MediumEditor.util.isDescendant(this.getToolbarElement(), event.target)) {
|
||
|
return false;
|
||
|
}
|
||
|
this.checkState();
|
||
|
},
|
||
|
|
||
|
handleEditableClick: function () {
|
||
|
// Delay the call to checkState to handle bug where selection is empty
|
||
|
// immediately after clicking inside a pre-existing selection
|
||
|
setTimeout(function () {
|
||
|
this.checkState();
|
||
|
}.bind(this), 0);
|
||
|
},
|
||
|
|
||
|
handleEditableKeyup: function () {
|
||
|
this.checkState();
|
||
|
},
|
||
|
|
||
|
handleBlur: function () {
|
||
|
// Kill any previously delayed calls to hide the toolbar
|
||
|
clearTimeout(this.hideTimeout);
|
||
|
|
||
|
// Blur may fire even if we have a selection, so we want to prevent any delayed showToolbar
|
||
|
// calls from happening in this specific case
|
||
|
clearTimeout(this.delayShowTimeout);
|
||
|
|
||
|
// Delay the call to hideToolbar to handle bug with multiple editors on the page at once
|
||
|
this.hideTimeout = setTimeout(function () {
|
||
|
this.hideToolbar();
|
||
|
}.bind(this), 1);
|
||
|
},
|
||
|
|
||
|
handleFocus: function () {
|
||
|
this.checkState();
|
||
|
},
|
||
|
|
||
|
// Hiding/showing toolbar
|
||
|
|
||
|
isDisplayed: function () {
|
||
|
return this.getToolbarElement().classList.contains('medium-editor-toolbar-active');
|
||
|
},
|
||
|
|
||
|
showToolbar: function () {
|
||
|
clearTimeout(this.hideTimeout);
|
||
|
if (!this.isDisplayed()) {
|
||
|
this.getToolbarElement().classList.add('medium-editor-toolbar-active');
|
||
|
this.trigger('showToolbar', {}, this.base.getFocusedElement());
|
||
|
}
|
||
|
},
|
||
|
|
||
|
hideToolbar: function () {
|
||
|
if (this.isDisplayed()) {
|
||
|
this.getToolbarElement().classList.remove('medium-editor-toolbar-active');
|
||
|
this.trigger('hideToolbar', {}, this.base.getFocusedElement());
|
||
|
}
|
||
|
},
|
||
|
|
||
|
isToolbarDefaultActionsDisplayed: function () {
|
||
|
return this.getToolbarActionsElement().style.display === 'block';
|
||
|
},
|
||
|
|
||
|
hideToolbarDefaultActions: function () {
|
||
|
if (this.isToolbarDefaultActionsDisplayed()) {
|
||
|
this.getToolbarActionsElement().style.display = 'none';
|
||
|
}
|
||
|
},
|
||
|
|
||
|
showToolbarDefaultActions: function () {
|
||
|
this.hideExtensionForms();
|
||
|
|
||
|
if (!this.isToolbarDefaultActionsDisplayed()) {
|
||
|
this.getToolbarActionsElement().style.display = 'block';
|
||
|
}
|
||
|
|
||
|
// Using setTimeout + options.delay because:
|
||
|
// We will actually be displaying the toolbar, which should be controlled by options.delay
|
||
|
this.delayShowTimeout = this.base.delay(function () {
|
||
|
this.showToolbar();
|
||
|
}.bind(this));
|
||
|
},
|
||
|
|
||
|
hideExtensionForms: function () {
|
||
|
// Hide all extension forms
|
||
|
this.forEachExtension(function (extension) {
|
||
|
if (extension.hasForm && extension.isDisplayed()) {
|
||
|
extension.hideForm();
|
||
|
}
|
||
|
});
|
||
|
},
|
||
|
|
||
|
// Responding to changes in user selection
|
||
|
|
||
|
// Checks for existance of multiple block elements in the current selection
|
||
|
multipleBlockElementsSelected: function () {
|
||
|
var regexEmptyHTMLTags = /<[^\/>][^>]*><\/[^>]+>/gim, // http://stackoverflow.com/questions/3129738/remove-empty-tags-using-regex
|
||
|
regexBlockElements = new RegExp('<(' + MediumEditor.util.blockContainerElementNames.join('|') + ')[^>]*>', 'g'),
|
||
|
selectionHTML = MediumEditor.selection.getSelectionHtml(this.document).replace(regexEmptyHTMLTags, ''), // Filter out empty blocks from selection
|
||
|
hasMultiParagraphs = selectionHTML.match(regexBlockElements); // Find how many block elements are within the html
|
||
|
|
||
|
return !!hasMultiParagraphs && hasMultiParagraphs.length > 1;
|
||
|
},
|
||
|
|
||
|
modifySelection: function () {
|
||
|
var selection = this.window.getSelection(),
|
||
|
selectionRange = selection.getRangeAt(0);
|
||
|
|
||
|
/*
|
||
|
* In firefox, there are cases (ie doubleclick of a word) where the selectionRange start
|
||
|
* will be at the very end of an element. In other browsers, the selectionRange start
|
||
|
* would instead be at the very beginning of an element that actually has content.
|
||
|
* example:
|
||
|
* <span>foo</span><span>bar</span>
|
||
|
*
|
||
|
* If the text 'bar' is selected, most browsers will have the selectionRange start at the beginning
|
||
|
* of the 'bar' span. However, there are cases where firefox will have the selectionRange start
|
||
|
* at the end of the 'foo' span. The contenteditable behavior will be ok, but if there are any
|
||
|
* properties on the 'bar' span, they won't be reflected accurately in the toolbar
|
||
|
* (ie 'Bold' button wouldn't be active)
|
||
|
*
|
||
|
* So, for cases where the selectionRange start is at the end of an element/node, find the next
|
||
|
* adjacent text node that actually has content in it, and move the selectionRange start there.
|
||
|
*/
|
||
|
if (this.standardizeSelectionStart &&
|
||
|
selectionRange.startContainer.nodeValue &&
|
||
|
(selectionRange.startOffset === selectionRange.startContainer.nodeValue.length)) {
|
||
|
var adjacentNode = MediumEditor.util.findAdjacentTextNodeWithContent(MediumEditor.selection.getSelectionElement(this.window), selectionRange.startContainer, this.document);
|
||
|
if (adjacentNode) {
|
||
|
var offset = 0;
|
||
|
while (adjacentNode.nodeValue.substr(offset, 1).trim().length === 0) {
|
||
|
offset = offset + 1;
|
||
|
}
|
||
|
selectionRange = MediumEditor.selection.select(this.document, adjacentNode, offset,
|
||
|
selectionRange.endContainer, selectionRange.endOffset);
|
||
|
}
|
||
|
}
|
||
|
},
|
||
|
|
||
|
checkState: function () {
|
||
|
if (this.base.preventSelectionUpdates) {
|
||
|
return;
|
||
|
}
|
||
|
|
||
|
// If no editable has focus OR selection is inside contenteditable = false
|
||
|
// hide toolbar
|
||
|
if (!this.base.getFocusedElement() ||
|
||
|
MediumEditor.selection.selectionInContentEditableFalse(this.window)) {
|
||
|
return this.hideToolbar();
|
||
|
}
|
||
|
|
||
|
// If there's no selection element, selection element doesn't belong to this editor
|
||
|
// or toolbar is disabled for this selection element
|
||
|
// hide toolbar
|
||
|
var selectionElement = MediumEditor.selection.getSelectionElement(this.window);
|
||
|
if (!selectionElement ||
|
||
|
this.getEditorElements().indexOf(selectionElement) === -1 ||
|
||
|
selectionElement.getAttribute('data-disable-toolbar')) {
|
||
|
return this.hideToolbar();
|
||
|
}
|
||
|
|
||
|
// Now we know there's a focused editable with a selection
|
||
|
|
||
|
// If the updateOnEmptySelection option is true, show the toolbar
|
||
|
if (this.updateOnEmptySelection && this.static) {
|
||
|
return this.showAndUpdateToolbar();
|
||
|
}
|
||
|
|
||
|
// If we don't have a 'valid' selection -> hide toolbar
|
||
|
if (!MediumEditor.selection.selectionContainsContent(this.document) ||
|
||
|
(this.allowMultiParagraphSelection === false && this.multipleBlockElementsSelected())) {
|
||
|
return this.hideToolbar();
|
||
|
}
|
||
|
|
||
|
this.showAndUpdateToolbar();
|
||
|
},
|
||
|
|
||
|
// Updating the toolbar
|
||
|
|
||
|
showAndUpdateToolbar: function () {
|
||
|
this.modifySelection();
|
||
|
this.setToolbarButtonStates();
|
||
|
this.trigger('positionToolbar', {}, this.base.getFocusedElement());
|
||
|
this.showToolbarDefaultActions();
|
||
|
this.setToolbarPosition();
|
||
|
},
|
||
|
|
||
|
setToolbarButtonStates: function () {
|
||
|
this.forEachExtension(function (extension) {
|
||
|
if (typeof extension.isActive === 'function' &&
|
||
|
typeof extension.setInactive === 'function') {
|
||
|
extension.setInactive();
|
||
|
}
|
||
|
});
|
||
|
|
||
|
this.checkActiveButtons();
|
||
|
},
|
||
|
|
||
|
checkActiveButtons: function () {
|
||
|
var manualStateChecks = [],
|
||
|
queryState = null,
|
||
|
selectionRange = MediumEditor.selection.getSelectionRange(this.document),
|
||
|
parentNode,
|
||
|
updateExtensionState = function (extension) {
|
||
|
if (typeof extension.checkState === 'function') {
|
||
|
extension.checkState(parentNode);
|
||
|
} else if (typeof extension.isActive === 'function' &&
|
||
|
typeof extension.isAlreadyApplied === 'function' &&
|
||
|
typeof extension.setActive === 'function') {
|
||
|
if (!extension.isActive() && extension.isAlreadyApplied(parentNode)) {
|
||
|
extension.setActive();
|
||
|
}
|
||
|
}
|
||
|
};
|
||
|
|
||
|
if (!selectionRange) {
|
||
|
return;
|
||
|
}
|
||
|
|
||
|
// Loop through all extensions
|
||
|
this.forEachExtension(function (extension) {
|
||
|
// For those extensions where we can use document.queryCommandState(), do so
|
||
|
if (typeof extension.queryCommandState === 'function') {
|
||
|
queryState = extension.queryCommandState();
|
||
|
// If queryCommandState returns a valid value, we can trust the browser
|
||
|
// and don't need to do our manual checks
|
||
|
if (queryState !== null) {
|
||
|
if (queryState && typeof extension.setActive === 'function') {
|
||
|
extension.setActive();
|
||
|
}
|
||
|
return;
|
||
|
}
|
||
|
}
|
||
|
// We can't use queryCommandState for this extension, so add to manualStateChecks
|
||
|
manualStateChecks.push(extension);
|
||
|
});
|
||
|
|
||
|
parentNode = MediumEditor.selection.getSelectedParentElement(selectionRange);
|
||
|
|
||
|
// Make sure the selection parent isn't outside of the contenteditable
|
||
|
if (!this.getEditorElements().some(function (element) {
|
||
|
return MediumEditor.util.isDescendant(element, parentNode, true);
|
||
|
})) {
|
||
|
return;
|
||
|
}
|
||
|
|
||
|
// Climb up the DOM and do manual checks for whether a certain extension is currently enabled for this node
|
||
|
while (parentNode) {
|
||
|
manualStateChecks.forEach(updateExtensionState);
|
||
|
|
||
|
// we can abort the search upwards if we leave the contentEditable element
|
||
|
if (MediumEditor.util.isMediumEditorElement(parentNode)) {
|
||
|
break;
|
||
|
}
|
||
|
parentNode = parentNode.parentNode;
|
||
|
}
|
||
|
},
|
||
|
|
||
|
// Positioning toolbar
|
||
|
|
||
|
positionToolbarIfShown: function () {
|
||
|
if (this.isDisplayed()) {
|
||
|
this.setToolbarPosition();
|
||
|
}
|
||
|
},
|
||
|
|
||
|
setToolbarPosition: function () {
|
||
|
var container = this.base.getFocusedElement(),
|
||
|
selection = this.window.getSelection();
|
||
|
|
||
|
// If there isn't a valid selection, bail
|
||
|
if (!container) {
|
||
|
return this;
|
||
|
}
|
||
|
|
||
|
if (this.static || !selection.isCollapsed) {
|
||
|
this.showToolbar();
|
||
|
|
||
|
// we don't need any absolute positioning if relativeContainer is set
|
||
|
if (!this.relativeContainer) {
|
||
|
if (this.static) {
|
||
|
this.positionStaticToolbar(container);
|
||
|
} else {
|
||
|
this.positionToolbar(selection);
|
||
|
}
|
||
|
}
|
||
|
|
||
|
this.trigger('positionedToolbar', {}, this.base.getFocusedElement());
|
||
|
}
|
||
|
},
|
||
|
|
||
|
positionStaticToolbar: function (container) {
|
||
|
// position the toolbar at left 0, so we can get the real width of the toolbar
|
||
|
this.getToolbarElement().style.left = '0';
|
||
|
|
||
|
// document.documentElement for IE 9
|
||
|
var scrollTop = (this.document.documentElement && this.document.documentElement.scrollTop) || this.document.body.scrollTop,
|
||
|
windowWidth = this.window.innerWidth,
|
||
|
toolbarElement = this.getToolbarElement(),
|
||
|
containerRect = container.getBoundingClientRect(),
|
||
|
containerTop = containerRect.top + scrollTop,
|
||
|
containerCenter = (containerRect.left + (containerRect.width / 2)),
|
||
|
toolbarHeight = toolbarElement.offsetHeight,
|
||
|
toolbarWidth = toolbarElement.offsetWidth,
|
||
|
halfOffsetWidth = toolbarWidth / 2,
|
||
|
targetLeft;
|
||
|
|
||
|
if (this.sticky) {
|
||
|
// If it's beyond the height of the editor, position it at the bottom of the editor
|
||
|
if (scrollTop > (containerTop + container.offsetHeight - toolbarHeight - this.stickyTopOffset)) {
|
||
|
toolbarElement.style.top = (containerTop + container.offsetHeight - toolbarHeight) + 'px';
|
||
|
toolbarElement.classList.remove('medium-editor-sticky-toolbar');
|
||
|
// Stick the toolbar to the top of the window
|
||
|
} else if (scrollTop > (containerTop - toolbarHeight - this.stickyTopOffset)) {
|
||
|
toolbarElement.classList.add('medium-editor-sticky-toolbar');
|
||
|
toolbarElement.style.top = this.stickyTopOffset + 'px';
|
||
|
// Normal static toolbar position
|
||
|
} else {
|
||
|
toolbarElement.classList.remove('medium-editor-sticky-toolbar');
|
||
|
toolbarElement.style.top = containerTop - toolbarHeight + 'px';
|
||
|
}
|
||
|
} else {
|
||
|
toolbarElement.style.top = containerTop - toolbarHeight + 'px';
|
||
|
}
|
||
|
|
||
|
switch (this.align) {
|
||
|
case 'left':
|
||
|
targetLeft = containerRect.left;
|
||
|
break;
|
||
|
|
||
|
case 'right':
|
||
|
targetLeft = containerRect.right - toolbarWidth;
|
||
|
break;
|
||
|
|
||
|
case 'center':
|
||
|
targetLeft = containerCenter - halfOffsetWidth;
|
||
|
break;
|
||
|
}
|
||
|
|
||
|
if (targetLeft < 0) {
|
||
|
targetLeft = 0;
|
||
|
} else if ((targetLeft + toolbarWidth) > windowWidth) {
|
||
|
targetLeft = (windowWidth - Math.ceil(toolbarWidth) - 1);
|
||
|
}
|
||
|
|
||
|
toolbarElement.style.left = targetLeft + 'px';
|
||
|
},
|
||
|
|
||
|
positionToolbar: function (selection) {
|
||
|
// position the toolbar at left 0, so we can get the real width of the toolbar
|
||
|
this.getToolbarElement().style.left = '0';
|
||
|
this.getToolbarElement().style.right = 'initial';
|
||
|
|
||
|
var range = selection.getRangeAt(0),
|
||
|
boundary = range.getBoundingClientRect();
|
||
|
|
||
|
// Handle selections with just images
|
||
|
if (!boundary || ((boundary.height === 0 && boundary.width === 0) && range.startContainer === range.endContainer)) {
|
||
|
// If there's a nested image, use that for the bounding rectangle
|
||
|
if (range.startContainer.nodeType === 1 && range.startContainer.querySelector('img')) {
|
||
|
boundary = range.startContainer.querySelector('img').getBoundingClientRect();
|
||
|
} else {
|
||
|
boundary = range.startContainer.getBoundingClientRect();
|
||
|
}
|
||
|
}
|
||
|
|
||
|
var containerWidth = this.window.innerWidth,
|
||
|
toolbarElement = this.getToolbarElement(),
|
||
|
toolbarHeight = toolbarElement.offsetHeight,
|
||
|
toolbarWidth = toolbarElement.offsetWidth,
|
||
|
halfOffsetWidth = toolbarWidth / 2,
|
||
|
buttonHeight = 50,
|
||
|
defaultLeft = this.diffLeft - halfOffsetWidth,
|
||
|
elementsContainer = this.getEditorOption('elementsContainer'),
|
||
|
elementsContainerAbsolute = ['absolute', 'fixed'].indexOf(window.getComputedStyle(elementsContainer).getPropertyValue('position')) > -1,
|
||
|
positions = {},
|
||
|
relativeBoundary = {},
|
||
|
middleBoundary, elementsContainerBoundary;
|
||
|
|
||
|
// If container element is absolute / fixed, recalculate boundaries to be relative to the container
|
||
|
if (elementsContainerAbsolute) {
|
||
|
elementsContainerBoundary = elementsContainer.getBoundingClientRect();
|
||
|
['top', 'left'].forEach(function (key) {
|
||
|
relativeBoundary[key] = boundary[key] - elementsContainerBoundary[key];
|
||
|
});
|
||
|
|
||
|
relativeBoundary.width = boundary.width;
|
||
|
relativeBoundary.height = boundary.height;
|
||
|
boundary = relativeBoundary;
|
||
|
|
||
|
containerWidth = elementsContainerBoundary.width;
|
||
|
|
||
|
// Adjust top position according to container scroll position
|
||
|
positions.top = elementsContainer.scrollTop;
|
||
|
} else {
|
||
|
// Adjust top position according to window scroll position
|
||
|
positions.top = this.window.pageYOffset;
|
||
|
}
|
||
|
|
||
|
middleBoundary = boundary.left + boundary.width / 2;
|
||
|
positions.top += boundary.top - toolbarHeight;
|
||
|
|
||
|
if (boundary.top < buttonHeight) {
|
||
|
toolbarElement.classList.add('medium-toolbar-arrow-over');
|
||
|
toolbarElement.classList.remove('medium-toolbar-arrow-under');
|
||
|
positions.top += buttonHeight + boundary.height - this.diffTop;
|
||
|
} else {
|
||
|
toolbarElement.classList.add('medium-toolbar-arrow-under');
|
||
|
toolbarElement.classList.remove('medium-toolbar-arrow-over');
|
||
|
positions.top += this.diffTop;
|
||
|
}
|
||
|
|
||
|
if (middleBoundary < halfOffsetWidth) {
|
||
|
positions.left = defaultLeft + halfOffsetWidth;
|
||
|
positions.right = 'initial';
|
||
|
} else if ((containerWidth - middleBoundary) < halfOffsetWidth) {
|
||
|
positions.left = 'auto';
|
||
|
positions.right = 0;
|
||
|
} else {
|
||
|
positions.left = defaultLeft + middleBoundary;
|
||
|
positions.right = 'initial';
|
||
|
}
|
||
|
|
||
|
['top', 'left', 'right'].forEach(function (key) {
|
||
|
toolbarElement.style[key] = positions[key] + (isNaN(positions[key]) ? '' : 'px');
|
||
|
});
|
||
|
}
|
||
|
});
|
||
|
|
||
|
MediumEditor.extensions.toolbar = Toolbar;
|
||
|
}());
|
||
|
|
||
|
(function () {
|
||
|
'use strict';
|
||
|
|
||
|
var ImageDragging = MediumEditor.Extension.extend({
|
||
|
init: function () {
|
||
|
MediumEditor.Extension.prototype.init.apply(this, arguments);
|
||
|
|
||
|
this.subscribe('editableDrag', this.handleDrag.bind(this));
|
||
|
this.subscribe('editableDrop', this.handleDrop.bind(this));
|
||
|
},
|
||
|
|
||
|
handleDrag: function (event) {
|
||
|
var className = 'medium-editor-dragover';
|
||
|
event.preventDefault();
|
||
|
event.dataTransfer.dropEffect = 'copy';
|
||
|
|
||
|
if (event.type === 'dragover') {
|
||
|
event.target.classList.add(className);
|
||
|
} else if (event.type === 'dragleave') {
|
||
|
event.target.classList.remove(className);
|
||
|
}
|
||
|
},
|
||
|
|
||
|
handleDrop: function (event) {
|
||
|
var className = 'medium-editor-dragover',
|
||
|
files;
|
||
|
event.preventDefault();
|
||
|
event.stopPropagation();
|
||
|
|
||
|
// IE9 does not support the File API, so prevent file from opening in a new window
|
||
|
// but also don't try to actually get the file
|
||
|
if (event.dataTransfer.files) {
|
||
|
files = Array.prototype.slice.call(event.dataTransfer.files, 0);
|
||
|
files.some(function (file) {
|
||
|
if (file.type.match('image')) {
|
||
|
var fileReader, id;
|
||
|
fileReader = new FileReader();
|
||
|
fileReader.readAsDataURL(file);
|
||
|
|
||
|
id = 'medium-img-' + (+new Date());
|
||
|
MediumEditor.util.insertHTMLCommand(this.document, '<img class="medium-editor-image-loading" id="' + id + '" />');
|
||
|
|
||
|
fileReader.onload = function () {
|
||
|
var img = this.document.getElementById(id);
|
||
|
if (img) {
|
||
|
img.removeAttribute('id');
|
||
|
img.removeAttribute('class');
|
||
|
img.src = fileReader.result;
|
||
|
}
|
||
|
}.bind(this);
|
||
|
}
|
||
|
}.bind(this));
|
||
|
}
|
||
|
event.target.classList.remove(className);
|
||
|
}
|
||
|
});
|
||
|
|
||
|
MediumEditor.extensions.imageDragging = ImageDragging;
|
||
|
}());
|
||
|
|
||
|
(function () {
|
||
|
'use strict';
|
||
|
|
||
|
// Event handlers that shouldn't be exposed externally
|
||
|
|
||
|
function handleDisableExtraSpaces(event) {
|
||
|
var node = MediumEditor.selection.getSelectionStart(this.options.ownerDocument),
|
||
|
textContent = node.textContent,
|
||
|
caretPositions = MediumEditor.selection.getCaretOffsets(node);
|
||
|
|
||
|
if ((textContent[caretPositions.left - 1] === undefined) || (textContent[caretPositions.left - 1].trim() === '') || (textContent[caretPositions.left] !== undefined && textContent[caretPositions.left].trim() === '')) {
|
||
|
event.preventDefault();
|
||
|
}
|
||
|
}
|
||
|
|
||
|
function handleDisabledEnterKeydown(event, element) {
|
||
|
if (this.options.disableReturn || element.getAttribute('data-disable-return')) {
|
||
|
event.preventDefault();
|
||
|
} else if (this.options.disableDoubleReturn || element.getAttribute('data-disable-double-return')) {
|
||
|
var node = MediumEditor.selection.getSelectionStart(this.options.ownerDocument);
|
||
|
|
||
|
// if current text selection is empty OR previous sibling text is empty OR it is not a list
|
||
|
if ((node && node.textContent.trim() === '' && node.nodeName.toLowerCase() !== 'li') ||
|
||
|
(node.previousElementSibling && node.previousElementSibling.nodeName.toLowerCase() !== 'br' &&
|
||
|
node.previousElementSibling.textContent.trim() === '')) {
|
||
|
event.preventDefault();
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
|
||
|
function handleTabKeydown(event) {
|
||
|
// Override tab only for pre nodes
|
||
|
var node = MediumEditor.selection.getSelectionStart(this.options.ownerDocument),
|
||
|
tag = node && node.nodeName.toLowerCase();
|
||
|
|
||
|
if (tag === 'pre') {
|
||
|
event.preventDefault();
|
||
|
MediumEditor.util.insertHTMLCommand(this.options.ownerDocument, ' ');
|
||
|
}
|
||
|
|
||
|
// Tab to indent list structures!
|
||
|
if (MediumEditor.util.isListItem(node)) {
|
||
|
event.preventDefault();
|
||
|
|
||
|
// If Shift is down, outdent, otherwise indent
|
||
|
if (event.shiftKey) {
|
||
|
this.options.ownerDocument.execCommand('outdent', false, null);
|
||
|
} else {
|
||
|
this.options.ownerDocument.execCommand('indent', false, null);
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
|
||
|
function handleBlockDeleteKeydowns(event) {
|
||
|
var p, node = MediumEditor.selection.getSelectionStart(this.options.ownerDocument),
|
||
|
tagName = node.nodeName.toLowerCase(),
|
||
|
isEmpty = /^(\s+|<br\/?>)?$/i,
|
||
|
isHeader = /h\d/i;
|
||
|
|
||
|
if (MediumEditor.util.isKey(event, [MediumEditor.util.keyCode.BACKSPACE, MediumEditor.util.keyCode.ENTER]) &&
|
||
|
// has a preceeding sibling
|
||
|
node.previousElementSibling &&
|
||
|
// in a header
|
||
|
isHeader.test(tagName) &&
|
||
|
// at the very end of the block
|
||
|
MediumEditor.selection.getCaretOffsets(node).left === 0) {
|
||
|
if (MediumEditor.util.isKey(event, MediumEditor.util.keyCode.BACKSPACE) && isEmpty.test(node.previousElementSibling.innerHTML)) {
|
||
|
// backspacing the begining of a header into an empty previous element will
|
||
|
// change the tagName of the current node to prevent one
|
||
|
// instead delete previous node and cancel the event.
|
||
|
node.previousElementSibling.parentNode.removeChild(node.previousElementSibling);
|
||
|
event.preventDefault();
|
||
|
} else if (!this.options.disableDoubleReturn && MediumEditor.util.isKey(event, MediumEditor.util.keyCode.ENTER)) {
|
||
|
// hitting return in the begining of a header will create empty header elements before the current one
|
||
|
// instead, make "<p><br></p>" element, which are what happens if you hit return in an empty paragraph
|
||
|
p = this.options.ownerDocument.createElement('p');
|
||
|
p.innerHTML = '<br>';
|
||
|
node.previousElementSibling.parentNode.insertBefore(p, node);
|
||
|
event.preventDefault();
|
||
|
}
|
||
|
} else if (MediumEditor.util.isKey(event, MediumEditor.util.keyCode.DELETE) &&
|
||
|
// between two sibling elements
|
||
|
node.nextElementSibling &&
|
||
|
node.previousElementSibling &&
|
||
|
// not in a header
|
||
|
!isHeader.test(tagName) &&
|
||
|
// in an empty tag
|
||
|
isEmpty.test(node.innerHTML) &&
|
||
|
// when the next tag *is* a header
|
||
|
isHeader.test(node.nextElementSibling.nodeName.toLowerCase())) {
|
||
|
// hitting delete in an empty element preceding a header, ex:
|
||
|
// <p>[CURSOR]</p><h1>Header</h1>
|
||
|
// Will cause the h1 to become a paragraph.
|
||
|
// Instead, delete the paragraph node and move the cursor to the begining of the h1
|
||
|
|
||
|
// remove node and move cursor to start of header
|
||
|
MediumEditor.selection.moveCursor(this.options.ownerDocument, node.nextElementSibling);
|
||
|
|
||
|
node.previousElementSibling.parentNode.removeChild(node);
|
||
|
|
||
|
event.preventDefault();
|
||
|
} else if (MediumEditor.util.isKey(event, MediumEditor.util.keyCode.BACKSPACE) &&
|
||
|
tagName === 'li' &&
|
||
|
// hitting backspace inside an empty li
|
||
|
isEmpty.test(node.innerHTML) &&
|
||
|
// is first element (no preceeding siblings)
|
||
|
!node.previousElementSibling &&
|
||
|
// parent also does not have a sibling
|
||
|
!node.parentElement.previousElementSibling &&
|
||
|
// is not the only li in a list
|
||
|
node.nextElementSibling &&
|
||
|
node.nextElementSibling.nodeName.toLowerCase() === 'li') {
|
||
|
// backspacing in an empty first list element in the first list (with more elements) ex:
|
||
|
// <ul><li>[CURSOR]</li><li>List Item 2</li></ul>
|
||
|
// will remove the first <li> but add some extra element before (varies based on browser)
|
||
|
// Instead, this will:
|
||
|
// 1) remove the list element
|
||
|
// 2) create a paragraph before the list
|
||
|
// 3) move the cursor into the paragraph
|
||
|
|
||
|
// create a paragraph before the list
|
||
|
p = this.options.ownerDocument.createElement('p');
|
||
|
p.innerHTML = '<br>';
|
||
|
node.parentElement.parentElement.insertBefore(p, node.parentElement);
|
||
|
|
||
|
// move the cursor into the new paragraph
|
||
|
MediumEditor.selection.moveCursor(this.options.ownerDocument, p);
|
||
|
|
||
|
// remove the list element
|
||
|
node.parentElement.removeChild(node);
|
||
|
|
||
|
event.preventDefault();
|
||
|
} else if (MediumEditor.util.isKey(event, MediumEditor.util.keyCode.BACKSPACE) &&
|
||
|
(MediumEditor.util.getClosestTag(node, 'blockquote') !== false) &&
|
||
|
MediumEditor.selection.getCaretOffsets(node).left === 0) {
|
||
|
|
||
|
// when cursor is at the begining of the element and the element is <blockquote>
|
||
|
// then pressing backspace key should change the <blockquote> to a <p> tag
|
||
|
event.preventDefault();
|
||
|
MediumEditor.util.execFormatBlock(this.options.ownerDocument, 'p');
|
||
|
} else if (MediumEditor.util.isKey(event, MediumEditor.util.keyCode.ENTER) &&
|
||
|
(MediumEditor.util.getClosestTag(node, 'blockquote') !== false) &&
|
||
|
MediumEditor.selection.getCaretOffsets(node).right === 0) {
|
||
|
|
||
|
// when cursor is at the end of <blockquote>,
|
||
|
// then pressing enter key should create <p> tag, not <blockquote>
|
||
|
p = this.options.ownerDocument.createElement('p');
|
||
|
p.innerHTML = '<br>';
|
||
|
node.parentElement.insertBefore(p, node.nextSibling);
|
||
|
|
||
|
// move the cursor into the new paragraph
|
||
|
MediumEditor.selection.moveCursor(this.options.ownerDocument, p);
|
||
|
|
||
|
event.preventDefault();
|
||
|
} else if (MediumEditor.util.isKey(event, MediumEditor.util.keyCode.BACKSPACE) &&
|
||
|
MediumEditor.util.isMediumEditorElement(node.parentElement) &&
|
||
|
!node.previousElementSibling &&
|
||
|
node.nextElementSibling &&
|
||
|
isEmpty.test(node.innerHTML)) {
|
||
|
|
||
|
// when cursor is in the first element, it's empty and user presses backspace,
|
||
|
// do delete action instead to get rid of the first element and move caret to 2nd
|
||
|
event.preventDefault();
|
||
|
MediumEditor.selection.moveCursor(this.options.ownerDocument, node.nextSibling);
|
||
|
node.parentElement.removeChild(node);
|
||
|
}
|
||
|
}
|
||
|
|
||
|
function handleKeyup(event) {
|
||
|
var node = MediumEditor.selection.getSelectionStart(this.options.ownerDocument),
|
||
|
tagName;
|
||
|
|
||
|
if (!node) {
|
||
|
return;
|
||
|
}
|
||
|
|
||
|
// https://github.com/yabwe/medium-editor/issues/994
|
||
|
// Firefox thrown an error when calling `formatBlock` on an empty editable blockContainer that's not a <div>
|
||
|
if (MediumEditor.util.isMediumEditorElement(node) && node.children.length === 0 && !MediumEditor.util.isBlockContainer(node)) {
|
||
|
this.options.ownerDocument.execCommand('formatBlock', false, 'p');
|
||
|
}
|
||
|
|
||
|
// https://github.com/yabwe/medium-editor/issues/834
|
||
|
// https://github.com/yabwe/medium-editor/pull/382
|
||
|
// Don't call format block if this is a block element (ie h1, figCaption, etc.)
|
||
|
if (MediumEditor.util.isKey(event, MediumEditor.util.keyCode.ENTER) &&
|
||
|
!MediumEditor.util.isListItem(node) &&
|
||
|
!MediumEditor.util.isBlockContainer(node)) {
|
||
|
|
||
|
tagName = node.nodeName.toLowerCase();
|
||
|
// For anchor tags, unlink
|
||
|
if (tagName === 'a') {
|
||
|
this.options.ownerDocument.execCommand('unlink', false, null);
|
||
|
} else if (!event.shiftKey && !event.ctrlKey) {
|
||
|
this.options.ownerDocument.execCommand('formatBlock', false, 'p');
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
|
||
|
function handleEditableInput(event, editable) {
|
||
|
var textarea = editable.parentNode.querySelector('textarea[medium-editor-textarea-id="' + editable.getAttribute('medium-editor-textarea-id') + '"]');
|
||
|
if (textarea) {
|
||
|
textarea.value = editable.innerHTML.trim();
|
||
|
}
|
||
|
}
|
||
|
|
||
|
// Internal helper methods which shouldn't be exposed externally
|
||
|
|
||
|
function addToEditors(win) {
|
||
|
if (!win._mediumEditors) {
|
||
|
// To avoid breaking users who are assuming that the unique id on
|
||
|
// medium-editor elements will start at 1, inserting a 'null' in the
|
||
|
// array so the unique-id can always map to the index of the editor instance
|
||
|
win._mediumEditors = [null];
|
||
|
}
|
||
|
|
||
|
// If this already has a unique id, re-use it
|
||
|
if (!this.id) {
|
||
|
this.id = win._mediumEditors.length;
|
||
|
}
|
||
|
|
||
|
win._mediumEditors[this.id] = this;
|
||
|
}
|
||
|
|
||
|
function removeFromEditors(win) {
|
||
|
if (!win._mediumEditors || !win._mediumEditors[this.id]) {
|
||
|
return;
|
||
|
}
|
||
|
|
||
|
/* Setting the instance to null in the array instead of deleting it allows:
|
||
|
* 1) Each instance to preserve its own unique-id, even after being destroyed
|
||
|
* and initialized again
|
||
|
* 2) The unique-id to always correspond to an index in the array of medium-editor
|
||
|
* instances. Thus, we will be able to look at a contenteditable, and determine
|
||
|
* which instance it belongs to, by indexing into the global array.
|
||
|
*/
|
||
|
win._mediumEditors[this.id] = null;
|
||
|
}
|
||
|
|
||
|
function createElementsArray(selector, doc, filterEditorElements) {
|
||
|
var elements = [];
|
||
|
|
||
|
if (!selector) {
|
||
|
selector = [];
|
||
|
}
|
||
|
// If string, use as query selector
|
||
|
if (typeof selector === 'string') {
|
||
|
selector = doc.querySelectorAll(selector);
|
||
|
}
|
||
|
// If element, put into array
|
||
|
if (MediumEditor.util.isElement(selector)) {
|
||
|
selector = [selector];
|
||
|
}
|
||
|
|
||
|
if (filterEditorElements) {
|
||
|
// Remove elements that have already been initialized by the editor
|
||
|
// selecotr might not be an array (ie NodeList) so use for loop
|
||
|
for (var i = 0; i < selector.length; i++) {
|
||
|
var el = selector[i];
|
||
|
if (MediumEditor.util.isElement(el) &&
|
||
|
!el.getAttribute('data-medium-editor-element') &&
|
||
|
!el.getAttribute('medium-editor-textarea-id')) {
|
||
|
elements.push(el);
|
||
|
}
|
||
|
}
|
||
|
} else {
|
||
|
// Convert NodeList (or other array like object) into an array
|
||
|
elements = Array.prototype.slice.apply(selector);
|
||
|
}
|
||
|
|
||
|
return elements;
|
||
|
}
|
||
|
|
||
|
function cleanupTextareaElement(element) {
|
||
|
var textarea = element.parentNode.querySelector('textarea[medium-editor-textarea-id="' + element.getAttribute('medium-editor-textarea-id') + '"]');
|
||
|
if (textarea) {
|
||
|
// Un-hide the textarea
|
||
|
textarea.classList.remove('medium-editor-hidden');
|
||
|
textarea.removeAttribute('medium-editor-textarea-id');
|
||
|
}
|
||
|
if (element.parentNode) {
|
||
|
element.parentNode.removeChild(element);
|
||
|
}
|
||
|
}
|
||
|
|
||
|
function setExtensionDefaults(extension, defaults) {
|
||
|
Object.keys(defaults).forEach(function (prop) {
|
||
|
if (extension[prop] === undefined) {
|
||
|
extension[prop] = defaults[prop];
|
||
|
}
|
||
|
});
|
||
|
return extension;
|
||
|
}
|
||
|
|
||
|
function initExtension(extension, name, instance) {
|
||
|
var extensionDefaults = {
|
||
|
'window': instance.options.contentWindow,
|
||
|
'document': instance.options.ownerDocument,
|
||
|
'base': instance
|
||
|
};
|
||
|
|
||
|
// Add default options into the extension
|
||
|
extension = setExtensionDefaults(extension, extensionDefaults);
|
||
|
|
||
|
// Call init on the extension
|
||
|
if (typeof extension.init === 'function') {
|
||
|
extension.init();
|
||
|
}
|
||
|
|
||
|
// Set extension name (if not already set)
|
||
|
if (!extension.name) {
|
||
|
extension.name = name;
|
||
|
}
|
||
|
return extension;
|
||
|
}
|
||
|
|
||
|
function isToolbarEnabled() {
|
||
|
// If any of the elements don't have the toolbar disabled
|
||
|
// We need a toolbar
|
||
|
if (this.elements.every(function (element) {
|
||
|
return !!element.getAttribute('data-disable-toolbar');
|
||
|
})) {
|
||
|
return false;
|
||
|
}
|
||
|
|
||
|
return this.options.toolbar !== false;
|
||
|
}
|
||
|
|
||
|
function isAnchorPreviewEnabled() {
|
||
|
// If toolbar is disabled, don't add
|
||
|
if (!isToolbarEnabled.call(this)) {
|
||
|
return false;
|
||
|
}
|
||
|
|
||
|
return this.options.anchorPreview !== false;
|
||
|
}
|
||
|
|
||
|
function isPlaceholderEnabled() {
|
||
|
return this.options.placeholder !== false;
|
||
|
}
|
||
|
|
||
|
function isAutoLinkEnabled() {
|
||
|
return this.options.autoLink !== false;
|
||
|
}
|
||
|
|
||
|
function isImageDraggingEnabled() {
|
||
|
return this.options.imageDragging !== false;
|
||
|
}
|
||
|
|
||
|
function isKeyboardCommandsEnabled() {
|
||
|
return this.options.keyboardCommands !== false;
|
||
|
}
|
||
|
|
||
|
function shouldUseFileDraggingExtension() {
|
||
|
// Since the file-dragging extension replaces the image-dragging extension,
|
||
|
// we need to check if the user passed an overrided image-dragging extension.
|
||
|
// If they have, to avoid breaking users, we won't use file-dragging extension.
|
||
|
return !this.options.extensions['imageDragging'];
|
||
|
}
|
||
|
|
||
|
function createContentEditable(textarea) {
|
||
|
var div = this.options.ownerDocument.createElement('div'),
|
||
|
now = Date.now(),
|
||
|
uniqueId = 'medium-editor-' + now,
|
||
|
atts = textarea.attributes;
|
||
|
|
||
|
// Some browsers can move pretty fast, since we're using a timestamp
|
||
|
// to make a unique-id, ensure that the id is actually unique on the page
|
||
|
while (this.options.ownerDocument.getElementById(uniqueId)) {
|
||
|
now++;
|
||
|
uniqueId = 'medium-editor-' + now;
|
||
|
}
|
||
|
|
||
|
div.className = textarea.className;
|
||
|
div.id = uniqueId;
|
||
|
div.innerHTML = textarea.value;
|
||
|
|
||
|
textarea.setAttribute('medium-editor-textarea-id', uniqueId);
|
||
|
|
||
|
// re-create all attributes from the textearea to the new created div
|
||
|
for (var i = 0, n = atts.length; i < n; i++) {
|
||
|
// do not re-create existing attributes
|
||
|
if (!div.hasAttribute(atts[i].nodeName)) {
|
||
|
div.setAttribute(atts[i].nodeName, atts[i].value);
|
||
|
}
|
||
|
}
|
||
|
|
||
|
// If textarea has a form, listen for reset on the form to clear
|
||
|
// the content of the created div
|
||
|
if (textarea.form) {
|
||
|
this.on(textarea.form, 'reset', function (event) {
|
||
|
if (!event.defaultPrevented) {
|
||
|
this.resetContent(this.options.ownerDocument.getElementById(uniqueId));
|
||
|
}
|
||
|
}.bind(this));
|
||
|
}
|
||
|
|
||
|
textarea.classList.add('medium-editor-hidden');
|
||
|
textarea.parentNode.insertBefore(
|
||
|
div,
|
||
|
textarea
|
||
|
);
|
||
|
|
||
|
return div;
|
||
|
}
|
||
|
|
||
|
function initElement(element, editorId) {
|
||
|
if (!element.getAttribute('data-medium-editor-element')) {
|
||
|
if (element.nodeName.toLowerCase() === 'textarea') {
|
||
|
element = createContentEditable.call(this, element);
|
||
|
|
||
|
// Make sure we only attach to editableInput once for <textarea> elements
|
||
|
if (!this.instanceHandleEditableInput) {
|
||
|
this.instanceHandleEditableInput = handleEditableInput.bind(this);
|
||
|
this.subscribe('editableInput', this.instanceHandleEditableInput);
|
||
|
}
|
||
|
}
|
||
|
|
||
|
if (!this.options.disableEditing && !element.getAttribute('data-disable-editing')) {
|
||
|
element.setAttribute('contentEditable', true);
|
||
|
element.setAttribute('spellcheck', this.options.spellcheck);
|
||
|
}
|
||
|
|
||
|
// Make sure we only attach to editableKeydownEnter once for disable-return options
|
||
|
if (!this.instanceHandleEditableKeydownEnter) {
|
||
|
if (element.getAttribute('data-disable-return') || element.getAttribute('data-disable-double-return')) {
|
||
|
this.instanceHandleEditableKeydownEnter = handleDisabledEnterKeydown.bind(this);
|
||
|
this.subscribe('editableKeydownEnter', this.instanceHandleEditableKeydownEnter);
|
||
|
}
|
||
|
}
|
||
|
|
||
|
// if we're not disabling return, add a handler to help handle cleanup
|
||
|
// for certain cases when enter is pressed
|
||
|
if (!this.options.disableReturn && !element.getAttribute('data-disable-return')) {
|
||
|
this.on(element, 'keyup', handleKeyup.bind(this));
|
||
|
}
|
||
|
|
||
|
var elementId = MediumEditor.util.guid();
|
||
|
|
||
|
element.setAttribute('data-medium-editor-element', true);
|
||
|
element.classList.add('medium-editor-element');
|
||
|
element.setAttribute('role', 'textbox');
|
||
|
element.setAttribute('aria-multiline', true);
|
||
|
element.setAttribute('data-medium-editor-editor-index', editorId);
|
||
|
// TODO: Merge data-medium-editor-element and medium-editor-index attributes for 6.0.0
|
||
|
// medium-editor-index is not named correctly anymore and can be re-purposed to signify
|
||
|
// whether the element has been initialized or not
|
||
|
element.setAttribute('medium-editor-index', elementId);
|
||
|
initialContent[elementId] = element.innerHTML;
|
||
|
|
||
|
this.events.attachAllEventsToElement(element);
|
||
|
}
|
||
|
|
||
|
return element;
|
||
|
}
|
||
|
|
||
|
function attachHandlers() {
|
||
|
// attach to tabs
|
||
|
this.subscribe('editableKeydownTab', handleTabKeydown.bind(this));
|
||
|
|
||
|
// Bind keys which can create or destroy a block element: backspace, delete, return
|
||
|
this.subscribe('editableKeydownDelete', handleBlockDeleteKeydowns.bind(this));
|
||
|
this.subscribe('editableKeydownEnter', handleBlockDeleteKeydowns.bind(this));
|
||
|
|
||
|
// Bind double space event
|
||
|
if (this.options.disableExtraSpaces) {
|
||
|
this.subscribe('editableKeydownSpace', handleDisableExtraSpaces.bind(this));
|
||
|
}
|
||
|
|
||
|
// Make sure we only attach to editableKeydownEnter once for disable-return options
|
||
|
if (!this.instanceHandleEditableKeydownEnter) {
|
||
|
// disabling return or double return
|
||
|
if (this.options.disableReturn || this.options.disableDoubleReturn) {
|
||
|
this.instanceHandleEditableKeydownEnter = handleDisabledEnterKeydown.bind(this);
|
||
|
this.subscribe('editableKeydownEnter', this.instanceHandleEditableKeydownEnter);
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
|
||
|
function initExtensions() {
|
||
|
|
||
|
this.extensions = [];
|
||
|
|
||
|
// Passed in extensions
|
||
|
Object.keys(this.options.extensions).forEach(function (name) {
|
||
|
// Always save the toolbar extension for last
|
||
|
if (name !== 'toolbar' && this.options.extensions[name]) {
|
||
|
this.extensions.push(initExtension(this.options.extensions[name], name, this));
|
||
|
}
|
||
|
}, this);
|
||
|
|
||
|
// 4 Cases for imageDragging + fileDragging extensons:
|
||
|
//
|
||
|
// 1. ImageDragging ON + No Custom Image Dragging Extension:
|
||
|
// * Use fileDragging extension (default options)
|
||
|
// 2. ImageDragging OFF + No Custom Image Dragging Extension:
|
||
|
// * Use fileDragging extension w/ images turned off
|
||
|
// 3. ImageDragging ON + Custom Image Dragging Extension:
|
||
|
// * Don't use fileDragging (could interfere with custom image dragging extension)
|
||
|
// 4. ImageDragging OFF + Custom Image Dragging:
|
||
|
// * Don't use fileDragging (could interfere with custom image dragging extension)
|
||
|
if (shouldUseFileDraggingExtension.call(this)) {
|
||
|
var opts = this.options.fileDragging;
|
||
|
if (!opts) {
|
||
|
opts = {};
|
||
|
|
||
|
// Image is in the 'allowedTypes' list by default.
|
||
|
// If imageDragging is off override the 'allowedTypes' list with an empty one
|
||
|
if (!isImageDraggingEnabled.call(this)) {
|
||
|
opts.allowedTypes = [];
|
||
|
}
|
||
|
}
|
||
|
this.addBuiltInExtension('fileDragging', opts);
|
||
|
}
|
||
|
|
||
|
// Built-in extensions
|
||
|
var builtIns = {
|
||
|
paste: true,
|
||
|
'anchor-preview': isAnchorPreviewEnabled.call(this),
|
||
|
autoLink: isAutoLinkEnabled.call(this),
|
||
|
keyboardCommands: isKeyboardCommandsEnabled.call(this),
|
||
|
placeholder: isPlaceholderEnabled.call(this)
|
||
|
};
|
||
|
Object.keys(builtIns).forEach(function (name) {
|
||
|
if (builtIns[name]) {
|
||
|
this.addBuiltInExtension(name);
|
||
|
}
|
||
|
}, this);
|
||
|
|
||
|
// Users can pass in a custom toolbar extension
|
||
|
// so check for that first and if it's not present
|
||
|
// just create the default toolbar
|
||
|
var toolbarExtension = this.options.extensions['toolbar'];
|
||
|
if (!toolbarExtension && isToolbarEnabled.call(this)) {
|
||
|
// Backwards compatability
|
||
|
var toolbarOptions = MediumEditor.util.extend({}, this.options.toolbar, {
|
||
|
allowMultiParagraphSelection: this.options.allowMultiParagraphSelection // deprecated
|
||
|
});
|
||
|
toolbarExtension = new MediumEditor.extensions.toolbar(toolbarOptions);
|
||
|
}
|
||
|
|
||
|
// If the toolbar is not disabled, so we actually have an extension
|
||
|
// initialize it and add it to the extensions array
|
||
|
if (toolbarExtension) {
|
||
|
this.extensions.push(initExtension(toolbarExtension, 'toolbar', this));
|
||
|
}
|
||
|
}
|
||
|
|
||
|
function mergeOptions(defaults, options) {
|
||
|
var deprecatedProperties = [
|
||
|
['allowMultiParagraphSelection', 'toolbar.allowMultiParagraphSelection']
|
||
|
];
|
||
|
// warn about using deprecated properties
|
||
|
if (options) {
|
||
|
deprecatedProperties.forEach(function (pair) {
|
||
|
if (options.hasOwnProperty(pair[0]) && options[pair[0]] !== undefined) {
|
||
|
MediumEditor.util.deprecated(pair[0], pair[1], 'v6.0.0');
|
||
|
}
|
||
|
});
|
||
|
}
|
||
|
|
||
|
return MediumEditor.util.defaults({}, options, defaults);
|
||
|
}
|
||
|
|
||
|
function execActionInternal(action, opts) {
|
||
|
/*jslint regexp: true*/
|
||
|
var appendAction = /^append-(.+)$/gi,
|
||
|
justifyAction = /justify([A-Za-z]*)$/g, /* Detecting if is justifyCenter|Right|Left */
|
||
|
match,
|
||
|
cmdValueArgument;
|
||
|
/*jslint regexp: false*/
|
||
|
|
||
|
// Actions starting with 'append-' should attempt to format a block of text ('formatBlock') using a specific
|
||
|
// type of block element (ie append-blockquote, append-h1, append-pre, etc.)
|
||
|
match = appendAction.exec(action);
|
||
|
if (match) {
|
||
|
return MediumEditor.util.execFormatBlock(this.options.ownerDocument, match[1]);
|
||
|
}
|
||
|
|
||
|
if (action === 'fontSize') {
|
||
|
// TODO: Deprecate support for opts.size in 6.0.0
|
||
|
if (opts.size) {
|
||
|
MediumEditor.util.deprecated('.size option for fontSize command', '.value', '6.0.0');
|
||
|
}
|
||
|
cmdValueArgument = opts.value || opts.size;
|
||
|
return this.options.ownerDocument.execCommand('fontSize', false, cmdValueArgument);
|
||
|
}
|
||
|
|
||
|
if (action === 'fontName') {
|
||
|
// TODO: Deprecate support for opts.name in 6.0.0
|
||
|
if (opts.name) {
|
||
|
MediumEditor.util.deprecated('.name option for fontName command', '.value', '6.0.0');
|
||
|
}
|
||
|
cmdValueArgument = opts.value || opts.name;
|
||
|
return this.options.ownerDocument.execCommand('fontName', false, cmdValueArgument);
|
||
|
}
|
||
|
|
||
|
if (action === 'createLink') {
|
||
|
return this.createLink(opts);
|
||
|
}
|
||
|
|
||
|
if (action === 'image') {
|
||
|
var src = this.options.contentWindow.getSelection().toString().trim();
|
||
|
return this.options.ownerDocument.execCommand('insertImage', false, src);
|
||
|
}
|
||
|
|
||
|
if (action === 'html') {
|
||
|
var html = this.options.contentWindow.getSelection().toString().trim();
|
||
|
return MediumEditor.util.insertHTMLCommand(this.options.ownerDocument, html);
|
||
|
}
|
||
|
|
||
|
/* Issue: https://github.com/yabwe/medium-editor/issues/595
|
||
|
* If the action is to justify the text */
|
||
|
if (justifyAction.exec(action)) {
|
||
|
var result = this.options.ownerDocument.execCommand(action, false, null),
|
||
|
parentNode = MediumEditor.selection.getSelectedParentElement(MediumEditor.selection.getSelectionRange(this.options.ownerDocument));
|
||
|
if (parentNode) {
|
||
|
cleanupJustifyDivFragments.call(this, MediumEditor.util.getTopBlockContainer(parentNode));
|
||
|
}
|
||
|
|
||
|
return result;
|
||
|
}
|
||
|
|
||
|
cmdValueArgument = opts && opts.value;
|
||
|
return this.options.ownerDocument.execCommand(action, false, cmdValueArgument);
|
||
|
}
|
||
|
|
||
|
/* If we've just justified text within a container block
|
||
|
* Chrome may have removed <br> elements and instead wrapped lines in <div> elements
|
||
|
* with a text-align property. If so, we want to fix this
|
||
|
*/
|
||
|
function cleanupJustifyDivFragments(blockContainer) {
|
||
|
if (!blockContainer) {
|
||
|
return;
|
||
|
}
|
||
|
|
||
|
var textAlign,
|
||
|
childDivs = Array.prototype.slice.call(blockContainer.childNodes).filter(function (element) {
|
||
|
var isDiv = element.nodeName.toLowerCase() === 'div';
|
||
|
if (isDiv && !textAlign) {
|
||
|
textAlign = element.style.textAlign;
|
||
|
}
|
||
|
return isDiv;
|
||
|
});
|
||
|
|
||
|
/* If we found child <div> elements with text-align style attributes
|
||
|
* we should fix this by:
|
||
|
*
|
||
|
* 1) Unwrapping each <div> which has a text-align style
|
||
|
* 2) Insert a <br> element after each set of 'unwrapped' div children
|
||
|
* 3) Set the text-align style of the parent block element
|
||
|
*/
|
||
|
if (childDivs.length) {
|
||
|
// Since we're mucking with the HTML, preserve selection
|
||
|
this.saveSelection();
|
||
|
childDivs.forEach(function (div) {
|
||
|
if (div.style.textAlign === textAlign) {
|
||
|
var lastChild = div.lastChild;
|
||
|
if (lastChild) {
|
||
|
// Instead of a div, extract the child elements and add a <br>
|
||
|
MediumEditor.util.unwrap(div, this.options.ownerDocument);
|
||
|
var br = this.options.ownerDocument.createElement('BR');
|
||
|
lastChild.parentNode.insertBefore(br, lastChild.nextSibling);
|
||
|
}
|
||
|
}
|
||
|
}, this);
|
||
|
blockContainer.style.textAlign = textAlign;
|
||
|
// We're done, so restore selection
|
||
|
this.restoreSelection();
|
||
|
}
|
||
|
}
|
||
|
|
||
|
var initialContent = {};
|
||
|
|
||
|
MediumEditor.prototype = {
|
||
|
// NOT DOCUMENTED - exposed for backwards compatability
|
||
|
init: function (elements, options) {
|
||
|
this.options = mergeOptions.call(this, this.defaults, options);
|
||
|
this.origElements = elements;
|
||
|
|
||
|
if (!this.options.elementsContainer) {
|
||
|
this.options.elementsContainer = this.options.ownerDocument.body;
|
||
|
}
|
||
|
|
||
|
return this.setup();
|
||
|
},
|
||
|
|
||
|
setup: function () {
|
||
|
if (this.isActive) {
|
||
|
return;
|
||
|
}
|
||
|
|
||
|
addToEditors.call(this, this.options.contentWindow);
|
||
|
this.events = new MediumEditor.Events(this);
|
||
|
this.elements = [];
|
||
|
|
||
|
this.addElements(this.origElements);
|
||
|
|
||
|
if (this.elements.length === 0) {
|
||
|
return;
|
||
|
}
|
||
|
|
||
|
this.isActive = true;
|
||
|
|
||
|
// Call initialization helpers
|
||
|
initExtensions.call(this);
|
||
|
attachHandlers.call(this);
|
||
|
},
|
||
|
|
||
|
destroy: function () {
|
||
|
if (!this.isActive) {
|
||
|
return;
|
||
|
}
|
||
|
|
||
|
this.isActive = false;
|
||
|
|
||
|
this.extensions.forEach(function (extension) {
|
||
|
if (typeof extension.destroy === 'function') {
|
||
|
extension.destroy();
|
||
|
}
|
||
|
}, this);
|
||
|
|
||
|
this.events.destroy();
|
||
|
|
||
|
this.elements.forEach(function (element) {
|
||
|
// Reset elements content, fix for issue where after editor destroyed the red underlines on spelling errors are left
|
||
|
if (this.options.spellcheck) {
|
||
|
element.innerHTML = element.innerHTML;
|
||
|
}
|
||
|
|
||
|
// cleanup extra added attributes
|
||
|
element.removeAttribute('contentEditable');
|
||
|
element.removeAttribute('spellcheck');
|
||
|
element.removeAttribute('data-medium-editor-element');
|
||
|
element.classList.remove('medium-editor-element');
|
||
|
element.removeAttribute('role');
|
||
|
element.removeAttribute('aria-multiline');
|
||
|
element.removeAttribute('medium-editor-index');
|
||
|
element.removeAttribute('data-medium-editor-editor-index');
|
||
|
|
||
|
// Remove any elements created for textareas
|
||
|
if (element.getAttribute('medium-editor-textarea-id')) {
|
||
|
cleanupTextareaElement(element);
|
||
|
}
|
||
|
}, this);
|
||
|
this.elements = [];
|
||
|
this.instanceHandleEditableKeydownEnter = null;
|
||
|
this.instanceHandleEditableInput = null;
|
||
|
|
||
|
removeFromEditors.call(this, this.options.contentWindow);
|
||
|
},
|
||
|
|
||
|
on: function (target, event, listener, useCapture) {
|
||
|
this.events.attachDOMEvent(target, event, listener, useCapture);
|
||
|
|
||
|
return this;
|
||
|
},
|
||
|
|
||
|
off: function (target, event, listener, useCapture) {
|
||
|
this.events.detachDOMEvent(target, event, listener, useCapture);
|
||
|
|
||
|
return this;
|
||
|
},
|
||
|
|
||
|
subscribe: function (event, listener) {
|
||
|
this.events.attachCustomEvent(event, listener);
|
||
|
|
||
|
return this;
|
||
|
},
|
||
|
|
||
|
unsubscribe: function (event, listener) {
|
||
|
this.events.detachCustomEvent(event, listener);
|
||
|
|
||
|
return this;
|
||
|
},
|
||
|
|
||
|
trigger: function (name, data, editable) {
|
||
|
this.events.triggerCustomEvent(name, data, editable);
|
||
|
|
||
|
return this;
|
||
|
},
|
||
|
|
||
|
delay: function (fn) {
|
||
|
var self = this;
|
||
|
return setTimeout(function () {
|
||
|
if (self.isActive) {
|
||
|
fn();
|
||
|
}
|
||
|
}, this.options.delay);
|
||
|
},
|
||
|
|
||
|
serialize: function () {
|
||
|
var i,
|
||
|
elementid,
|
||
|
content = {},
|
||
|
len = this.elements.length;
|
||
|
|
||
|
for (i = 0; i < len; i += 1) {
|
||
|
elementid = (this.elements[i].id !== '') ? this.elements[i].id : 'element-' + i;
|
||
|
content[elementid] = {
|
||
|
value: this.elements[i].innerHTML.trim()
|
||
|
};
|
||
|
}
|
||
|
return content;
|
||
|
},
|
||
|
|
||
|
getExtensionByName: function (name) {
|
||
|
var extension;
|
||
|
if (this.extensions && this.extensions.length) {
|
||
|
this.extensions.some(function (ext) {
|
||
|
if (ext.name === name) {
|
||
|
extension = ext;
|
||
|
return true;
|
||
|
}
|
||
|
return false;
|
||
|
});
|
||
|
}
|
||
|
return extension;
|
||
|
},
|
||
|
|
||
|
/**
|
||
|
* NOT DOCUMENTED - exposed as a helper for other extensions to use
|
||
|
*/
|
||
|
addBuiltInExtension: function (name, opts) {
|
||
|
var extension = this.getExtensionByName(name),
|
||
|
merged;
|
||
|
if (extension) {
|
||
|
return extension;
|
||
|
}
|
||
|
|
||
|
switch (name) {
|
||
|
case 'anchor':
|
||
|
merged = MediumEditor.util.extend({}, this.options.anchor, opts);
|
||
|
extension = new MediumEditor.extensions.anchor(merged);
|
||
|
break;
|
||
|
case 'anchor-preview':
|
||
|
extension = new MediumEditor.extensions.anchorPreview(this.options.anchorPreview);
|
||
|
break;
|
||
|
case 'autoLink':
|
||
|
extension = new MediumEditor.extensions.autoLink();
|
||
|
break;
|
||
|
case 'fileDragging':
|
||
|
extension = new MediumEditor.extensions.fileDragging(opts);
|
||
|
break;
|
||
|
case 'fontname':
|
||
|
extension = new MediumEditor.extensions.fontName(this.options.fontName);
|
||
|
break;
|
||
|
case 'fontsize':
|
||
|
extension = new MediumEditor.extensions.fontSize(opts);
|
||
|
break;
|
||
|
case 'keyboardCommands':
|
||
|
extension = new MediumEditor.extensions.keyboardCommands(this.options.keyboardCommands);
|
||
|
break;
|
||
|
case 'paste':
|
||
|
extension = new MediumEditor.extensions.paste(this.options.paste);
|
||
|
break;
|
||
|
case 'placeholder':
|
||
|
extension = new MediumEditor.extensions.placeholder(this.options.placeholder);
|
||
|
break;
|
||
|
default:
|
||
|
// All of the built-in buttons for MediumEditor are extensions
|
||
|
// so check to see if the extension we're creating is a built-in button
|
||
|
if (MediumEditor.extensions.button.isBuiltInButton(name)) {
|
||
|
if (opts) {
|
||
|
merged = MediumEditor.util.defaults({}, opts, MediumEditor.extensions.button.prototype.defaults[name]);
|
||
|
extension = new MediumEditor.extensions.button(merged);
|
||
|
} else {
|
||
|
extension = new MediumEditor.extensions.button(name);
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
|
||
|
if (extension) {
|
||
|
this.extensions.push(initExtension(extension, name, this));
|
||
|
}
|
||
|
|
||
|
return extension;
|
||
|
},
|
||
|
|
||
|
stopSelectionUpdates: function () {
|
||
|
this.preventSelectionUpdates = true;
|
||
|
},
|
||
|
|
||
|
startSelectionUpdates: function () {
|
||
|
this.preventSelectionUpdates = false;
|
||
|
},
|
||
|
|
||
|
checkSelection: function () {
|
||
|
var toolbar = this.getExtensionByName('toolbar');
|
||
|
if (toolbar) {
|
||
|
toolbar.checkState();
|
||
|
}
|
||
|
return this;
|
||
|
},
|
||
|
|
||
|
// Wrapper around document.queryCommandState for checking whether an action has already
|
||
|
// been applied to the current selection
|
||
|
queryCommandState: function (action) {
|
||
|
var fullAction = /^full-(.+)$/gi,
|
||
|
match,
|
||
|
queryState = null;
|
||
|
|
||
|
// Actions starting with 'full-' need to be modified since this is a medium-editor concept
|
||
|
match = fullAction.exec(action);
|
||
|
if (match) {
|
||
|
action = match[1];
|
||
|
}
|
||
|
|
||
|
try {
|
||
|
queryState = this.options.ownerDocument.queryCommandState(action);
|
||
|
} catch (exc) {
|
||
|
queryState = null;
|
||
|
}
|
||
|
|
||
|
return queryState;
|
||
|
},
|
||
|
|
||
|
execAction: function (action, opts) {
|
||
|
/*jslint regexp: true*/
|
||
|
var fullAction = /^full-(.+)$/gi,
|
||
|
match,
|
||
|
result;
|
||
|
/*jslint regexp: false*/
|
||
|
|
||
|
// Actions starting with 'full-' should be applied to to the entire contents of the editable element
|
||
|
// (ie full-bold, full-append-pre, etc.)
|
||
|
match = fullAction.exec(action);
|
||
|
if (match) {
|
||
|
// Store the current selection to be restored after applying the action
|
||
|
this.saveSelection();
|
||
|
// Select all of the contents before calling the action
|
||
|
this.selectAllContents();
|
||
|
result = execActionInternal.call(this, match[1], opts);
|
||
|
// Restore the previous selection
|
||
|
this.restoreSelection();
|
||
|
} else {
|
||
|
result = execActionInternal.call(this, action, opts);
|
||
|
}
|
||
|
|
||
|
// do some DOM clean-up for known browser issues after the action
|
||
|
if (action === 'insertunorderedlist' || action === 'insertorderedlist') {
|
||
|
MediumEditor.util.cleanListDOM(this.options.ownerDocument, this.getSelectedParentElement());
|
||
|
}
|
||
|
|
||
|
this.checkSelection();
|
||
|
return result;
|
||
|
},
|
||
|
|
||
|
getSelectedParentElement: function (range) {
|
||
|
if (range === undefined) {
|
||
|
range = this.options.contentWindow.getSelection().getRangeAt(0);
|
||
|
}
|
||
|
return MediumEditor.selection.getSelectedParentElement(range);
|
||
|
},
|
||
|
|
||
|
selectAllContents: function () {
|
||
|
var currNode = MediumEditor.selection.getSelectionElement(this.options.contentWindow);
|
||
|
|
||
|
if (currNode) {
|
||
|
// Move to the lowest descendant node that still selects all of the contents
|
||
|
while (currNode.children.length === 1) {
|
||
|
currNode = currNode.children[0];
|
||
|
}
|
||
|
|
||
|
this.selectElement(currNode);
|
||
|
}
|
||
|
},
|
||
|
|
||
|
selectElement: function (element) {
|
||
|
MediumEditor.selection.selectNode(element, this.options.ownerDocument);
|
||
|
|
||
|
var selElement = MediumEditor.selection.getSelectionElement(this.options.contentWindow);
|
||
|
if (selElement) {
|
||
|
this.events.focusElement(selElement);
|
||
|
}
|
||
|
},
|
||
|
|
||
|
getFocusedElement: function () {
|
||
|
var focused;
|
||
|
this.elements.some(function (element) {
|
||
|
// Find the element that has focus
|
||
|
if (!focused && element.getAttribute('data-medium-focused')) {
|
||
|
focused = element;
|
||
|
}
|
||
|
|
||
|
// bail if we found the element that had focus
|
||
|
return !!focused;
|
||
|
}, this);
|
||
|
|
||
|
return focused;
|
||
|
},
|
||
|
|
||
|
// Export the state of the selection in respect to one of this
|
||
|
// instance of MediumEditor's elements
|
||
|
exportSelection: function () {
|
||
|
var selectionElement = MediumEditor.selection.getSelectionElement(this.options.contentWindow),
|
||
|
editableElementIndex = this.elements.indexOf(selectionElement),
|
||
|
selectionState = null;
|
||
|
|
||
|
if (editableElementIndex >= 0) {
|
||
|
selectionState = MediumEditor.selection.exportSelection(selectionElement, this.options.ownerDocument);
|
||
|
}
|
||
|
|
||
|
if (selectionState !== null && editableElementIndex !== 0) {
|
||
|
selectionState.editableElementIndex = editableElementIndex;
|
||
|
}
|
||
|
|
||
|
return selectionState;
|
||
|
},
|
||
|
|
||
|
saveSelection: function () {
|
||
|
this.selectionState = this.exportSelection();
|
||
|
},
|
||
|
|
||
|
// Restore a selection based on a selectionState returned by a call
|
||
|
// to MediumEditor.exportSelection
|
||
|
importSelection: function (selectionState, favorLaterSelectionAnchor) {
|
||
|
if (!selectionState) {
|
||
|
return;
|
||
|
}
|
||
|
|
||
|
var editableElement = this.elements[selectionState.editableElementIndex || 0];
|
||
|
MediumEditor.selection.importSelection(selectionState, editableElement, this.options.ownerDocument, favorLaterSelectionAnchor);
|
||
|
},
|
||
|
|
||
|
restoreSelection: function () {
|
||
|
this.importSelection(this.selectionState);
|
||
|
},
|
||
|
|
||
|
createLink: function (opts) {
|
||
|
var currentEditor = MediumEditor.selection.getSelectionElement(this.options.contentWindow),
|
||
|
customEvent = {},
|
||
|
targetUrl;
|
||
|
|
||
|
// Make sure the selection is within an element this editor is tracking
|
||
|
if (this.elements.indexOf(currentEditor) === -1) {
|
||
|
return;
|
||
|
}
|
||
|
|
||
|
try {
|
||
|
this.events.disableCustomEvent('editableInput');
|
||
|
// TODO: Deprecate support for opts.url in 6.0.0
|
||
|
if (opts.url) {
|
||
|
MediumEditor.util.deprecated('.url option for createLink', '.value', '6.0.0');
|
||
|
}
|
||
|
targetUrl = opts.url || opts.value;
|
||
|
if (targetUrl && targetUrl.trim().length > 0) {
|
||
|
var currentSelection = this.options.contentWindow.getSelection();
|
||
|
if (currentSelection) {
|
||
|
var currRange = currentSelection.getRangeAt(0),
|
||
|
commonAncestorContainer = currRange.commonAncestorContainer,
|
||
|
exportedSelection,
|
||
|
startContainerParentElement,
|
||
|
endContainerParentElement,
|
||
|
textNodes;
|
||
|
|
||
|
// If the selection is contained within a single text node
|
||
|
// and the selection starts at the beginning of the text node,
|
||
|
// MSIE still says the startContainer is the parent of the text node.
|
||
|
// If the selection is contained within a single text node, we
|
||
|
// want to just use the default browser 'createLink', so we need
|
||
|
// to account for this case and adjust the commonAncestorContainer accordingly
|
||
|
if (currRange.endContainer.nodeType === 3 &&
|
||
|
currRange.startContainer.nodeType !== 3 &&
|
||
|
currRange.startOffset === 0 &&
|
||
|
currRange.startContainer.firstChild === currRange.endContainer) {
|
||
|
commonAncestorContainer = currRange.endContainer;
|
||
|
}
|
||
|
|
||
|
startContainerParentElement = MediumEditor.util.getClosestBlockContainer(currRange.startContainer);
|
||
|
endContainerParentElement = MediumEditor.util.getClosestBlockContainer(currRange.endContainer);
|
||
|
|
||
|
// If the selection is not contained within a single text node
|
||
|
// but the selection is contained within the same block element
|
||
|
// we want to make sure we create a single link, and not multiple links
|
||
|
// which can happen with the built in browser functionality
|
||
|
if (commonAncestorContainer.nodeType !== 3 && commonAncestorContainer.textContent.length !== 0 && startContainerParentElement === endContainerParentElement) {
|
||
|
var parentElement = (startContainerParentElement || currentEditor),
|
||
|
fragment = this.options.ownerDocument.createDocumentFragment();
|
||
|
|
||
|
// since we are going to create a link from an extracted text,
|
||
|
// be sure that if we are updating a link, we won't let an empty link behind (see #754)
|
||
|
// (Workaroung for Chrome)
|
||
|
this.execAction('unlink');
|
||
|
|
||
|
exportedSelection = this.exportSelection();
|
||
|
fragment.appendChild(parentElement.cloneNode(true));
|
||
|
|
||
|
if (currentEditor === parentElement) {
|
||
|
// We have to avoid the editor itself being wiped out when it's the only block element,
|
||
|
// as our reference inside this.elements gets detached from the page when insertHTML runs.
|
||
|
// If we just use [parentElement, 0] and [parentElement, parentElement.childNodes.length]
|
||
|
// as the range boundaries, this happens whenever parentElement === currentEditor.
|
||
|
// The tradeoff to this workaround is that a orphaned tag can sometimes be left behind at
|
||
|
// the end of the editor's content.
|
||
|
// In Gecko:
|
||
|
// as an empty <strong></strong> if parentElement.lastChild is a <strong> tag.
|
||
|
// In WebKit:
|
||
|
// an invented <br /> tag at the end in the same situation
|
||
|
MediumEditor.selection.select(
|
||
|
this.options.ownerDocument,
|
||
|
parentElement.firstChild,
|
||
|
0,
|
||
|
parentElement.lastChild,
|
||
|
parentElement.lastChild.nodeType === 3 ?
|
||
|
parentElement.lastChild.nodeValue.length : parentElement.lastChild.childNodes.length
|
||
|
);
|
||
|
} else {
|
||
|
MediumEditor.selection.select(
|
||
|
this.options.ownerDocument,
|
||
|
parentElement,
|
||
|
0,
|
||
|
parentElement,
|
||
|
parentElement.childNodes.length
|
||
|
);
|
||
|
}
|
||
|
|
||
|
var modifiedExportedSelection = this.exportSelection();
|
||
|
|
||
|
textNodes = MediumEditor.util.findOrCreateMatchingTextNodes(
|
||
|
this.options.ownerDocument,
|
||
|
fragment,
|
||
|
{
|
||
|
start: exportedSelection.start - modifiedExportedSelection.start,
|
||
|
end: exportedSelection.end - modifiedExportedSelection.start,
|
||
|
editableElementIndex: exportedSelection.editableElementIndex
|
||
|
}
|
||
|
);
|
||
|
// If textNodes are not present, when changing link on images
|
||
|
// ex: <a><img src="http://image.test.com"></a>, change fragment to currRange.startContainer
|
||
|
// and set textNodes array to [imageElement, imageElement]
|
||
|
if (textNodes.length === 0) {
|
||
|
fragment = this.options.ownerDocument.createDocumentFragment();
|
||
|
fragment.appendChild(commonAncestorContainer.cloneNode(true));
|
||
|
textNodes = [fragment.firstChild.firstChild, fragment.firstChild.lastChild];
|
||
|
}
|
||
|
|
||
|
// Creates the link in the document fragment
|
||
|
MediumEditor.util.createLink(this.options.ownerDocument, textNodes, targetUrl.trim());
|
||
|
|
||
|
// Chrome trims the leading whitespaces when inserting HTML, which messes up restoring the selection.
|
||
|
var leadingWhitespacesCount = (fragment.firstChild.innerHTML.match(/^\s+/) || [''])[0].length;
|
||
|
|
||
|
// Now move the created link back into the original document in a way to preserve undo/redo history
|
||
|
MediumEditor.util.insertHTMLCommand(this.options.ownerDocument, fragment.firstChild.innerHTML.replace(/^\s+/, ''));
|
||
|
exportedSelection.start -= leadingWhitespacesCount;
|
||
|
exportedSelection.end -= leadingWhitespacesCount;
|
||
|
|
||
|
this.importSelection(exportedSelection);
|
||
|
} else {
|
||
|
this.options.ownerDocument.execCommand('createLink', false, targetUrl);
|
||
|
}
|
||
|
|
||
|
if (this.options.targetBlank || opts.target === '_blank') {
|
||
|
MediumEditor.util.setTargetBlank(MediumEditor.selection.getSelectionStart(this.options.ownerDocument), targetUrl);
|
||
|
} else {
|
||
|
MediumEditor.util.removeTargetBlank(MediumEditor.selection.getSelectionStart(this.options.ownerDocument), targetUrl);
|
||
|
}
|
||
|
|
||
|
if (opts.buttonClass) {
|
||
|
MediumEditor.util.addClassToAnchors(MediumEditor.selection.getSelectionStart(this.options.ownerDocument), opts.buttonClass);
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
// Fire input event for backwards compatibility if anyone was listening directly to the DOM input event
|
||
|
if (this.options.targetBlank || opts.target === '_blank' || opts.buttonClass) {
|
||
|
customEvent = this.options.ownerDocument.createEvent('HTMLEvents');
|
||
|
customEvent.initEvent('input', true, true, this.options.contentWindow);
|
||
|
for (var i = 0, len = this.elements.length; i < len; i += 1) {
|
||
|
this.elements[i].dispatchEvent(customEvent);
|
||
|
}
|
||
|
}
|
||
|
} finally {
|
||
|
this.events.enableCustomEvent('editableInput');
|
||
|
}
|
||
|
// Fire our custom editableInput event
|
||
|
this.events.triggerCustomEvent('editableInput', customEvent, currentEditor);
|
||
|
},
|
||
|
|
||
|
cleanPaste: function (text) {
|
||
|
this.getExtensionByName('paste').cleanPaste(text);
|
||
|
},
|
||
|
|
||
|
pasteHTML: function (html, options) {
|
||
|
this.getExtensionByName('paste').pasteHTML(html, options);
|
||
|
},
|
||
|
|
||
|
setContent: function (html, index) {
|
||
|
index = index || 0;
|
||
|
|
||
|
if (this.elements[index]) {
|
||
|
var target = this.elements[index];
|
||
|
target.innerHTML = html;
|
||
|
this.checkContentChanged(target);
|
||
|
}
|
||
|
},
|
||
|
|
||
|
getContent: function (index) {
|
||
|
index = index || 0;
|
||
|
|
||
|
if (this.elements[index]) {
|
||
|
return this.elements[index].innerHTML.trim();
|
||
|
}
|
||
|
return null;
|
||
|
},
|
||
|
|
||
|
checkContentChanged: function (editable) {
|
||
|
editable = editable || MediumEditor.selection.getSelectionElement(this.options.contentWindow);
|
||
|
this.events.updateInput(editable, { target: editable, currentTarget: editable });
|
||
|
},
|
||
|
|
||
|
resetContent: function (element) {
|
||
|
// For all elements that exist in the this.elements array, we can assume:
|
||
|
// - Its initial content has been set in the initialContent object
|
||
|
// - It has a medium-editor-index attribute which is the key value in the initialContent object
|
||
|
|
||
|
if (element) {
|
||
|
var index = this.elements.indexOf(element);
|
||
|
if (index !== -1) {
|
||
|
this.setContent(initialContent[element.getAttribute('medium-editor-index')], index);
|
||
|
}
|
||
|
return;
|
||
|
}
|
||
|
|
||
|
this.elements.forEach(function (el, idx) {
|
||
|
this.setContent(initialContent[el.getAttribute('medium-editor-index')], idx);
|
||
|
}, this);
|
||
|
},
|
||
|
|
||
|
addElements: function (selector) {
|
||
|
// Convert elements into an array
|
||
|
var elements = createElementsArray(selector, this.options.ownerDocument, true);
|
||
|
|
||
|
// Do we have elements to add now?
|
||
|
if (elements.length === 0) {
|
||
|
return false;
|
||
|
}
|
||
|
|
||
|
elements.forEach(function (element) {
|
||
|
// Initialize all new elements (we check that in those functions don't worry)
|
||
|
element = initElement.call(this, element, this.id);
|
||
|
|
||
|
// Add new elements to our internal elements array
|
||
|
this.elements.push(element);
|
||
|
|
||
|
// Trigger event so extensions can know when an element has been added
|
||
|
this.trigger('addElement', { target: element, currentTarget: element }, element);
|
||
|
}, this);
|
||
|
},
|
||
|
|
||
|
removeElements: function (selector) {
|
||
|
// Convert elements into an array
|
||
|
var elements = createElementsArray(selector, this.options.ownerDocument),
|
||
|
toRemove = elements.map(function (el) {
|
||
|
// For textareas, make sure we're looking at the editor div and not the textarea itself
|
||
|
if (el.getAttribute('medium-editor-textarea-id') && el.parentNode) {
|
||
|
return el.parentNode.querySelector('div[medium-editor-textarea-id="' + el.getAttribute('medium-editor-textarea-id') + '"]');
|
||
|
} else {
|
||
|
return el;
|
||
|
}
|
||
|
});
|
||
|
|
||
|
this.elements = this.elements.filter(function (element) {
|
||
|
// If this is an element we want to remove
|
||
|
if (toRemove.indexOf(element) !== -1) {
|
||
|
this.events.cleanupElement(element);
|
||
|
if (element.getAttribute('medium-editor-textarea-id')) {
|
||
|
cleanupTextareaElement(element);
|
||
|
}
|
||
|
// Trigger event so extensions can clean-up elements that are being removed
|
||
|
this.trigger('removeElement', { target: element, currentTarget: element }, element);
|
||
|
return false;
|
||
|
}
|
||
|
return true;
|
||
|
}, this);
|
||
|
}
|
||
|
};
|
||
|
|
||
|
MediumEditor.getEditorFromElement = function (element) {
|
||
|
var index = element.getAttribute('data-medium-editor-editor-index'),
|
||
|
win = element && element.ownerDocument && (element.ownerDocument.defaultView || element.ownerDocument.parentWindow);
|
||
|
if (win && win._mediumEditors && win._mediumEditors[index]) {
|
||
|
return win._mediumEditors[index];
|
||
|
}
|
||
|
return null;
|
||
|
};
|
||
|
}());
|
||
|
|
||
|
(function () {
|
||
|
// summary: The default options hash used by the Editor
|
||
|
|
||
|
MediumEditor.prototype.defaults = {
|
||
|
activeButtonClass: 'medium-editor-button-active',
|
||
|
buttonLabels: false,
|
||
|
delay: 0,
|
||
|
disableReturn: false,
|
||
|
disableDoubleReturn: false,
|
||
|
disableExtraSpaces: false,
|
||
|
disableEditing: false,
|
||
|
autoLink: false,
|
||
|
elementsContainer: false,
|
||
|
contentWindow: window,
|
||
|
ownerDocument: document,
|
||
|
targetBlank: false,
|
||
|
extensions: {},
|
||
|
spellcheck: true
|
||
|
};
|
||
|
})();
|
||
|
|
||
|
MediumEditor.parseVersionString = function (release) {
|
||
|
var split = release.split('-'),
|
||
|
version = split[0].split('.'),
|
||
|
preRelease = (split.length > 1) ? split[1] : '';
|
||
|
return {
|
||
|
major: parseInt(version[0], 10),
|
||
|
minor: parseInt(version[1], 10),
|
||
|
revision: parseInt(version[2], 10),
|
||
|
preRelease: preRelease,
|
||
|
toString: function () {
|
||
|
return [version[0], version[1], version[2]].join('.') + (preRelease ? '-' + preRelease : '');
|
||
|
}
|
||
|
};
|
||
|
};
|
||
|
|
||
|
MediumEditor.version = MediumEditor.parseVersionString.call(this, ({
|
||
|
// grunt-bump looks for this:
|
||
|
'version': '5.23.3'
|
||
|
}).version);
|
||
|
|
||
|
return MediumEditor;
|
||
|
}()));
|