upfront-editable
Version:
Friendly contenteditable API
602 lines (494 loc) • 18.3 kB
JavaScript
'use strict';
var _classCallCheck2 = require('babel-runtime/helpers/classCallCheck');
var _classCallCheck3 = _interopRequireDefault(_classCallCheck2);
var _createClass2 = require('babel-runtime/helpers/createClass');
var _createClass3 = _interopRequireDefault(_createClass2);
var _jquery = require('jquery');
var _jquery2 = _interopRequireDefault(_jquery);
var _rangy = require('rangy');
var _rangy2 = _interopRequireDefault(_rangy);
var _config = require('./config');
var config = _interopRequireWildcard(_config);
var _error = require('./util/error');
var _error2 = _interopRequireDefault(_error);
var _parser = require('./parser');
var parser = _interopRequireWildcard(_parser);
var _block = require('./block');
var block = _interopRequireWildcard(_block);
var _content = require('./content');
var content = _interopRequireWildcard(_content);
var _clipboard = require('./clipboard');
var clipboard = _interopRequireWildcard(_clipboard);
var _dispatcher = require('./dispatcher');
var _dispatcher2 = _interopRequireDefault(_dispatcher);
var _cursor = require('./cursor');
var _cursor2 = _interopRequireDefault(_cursor);
var _highlightSupport = require('./highlight-support');
var _highlightSupport2 = _interopRequireDefault(_highlightSupport);
var _highlighting = require('./highlighting');
var _highlighting2 = _interopRequireDefault(_highlighting);
var _createDefaultEvents = require('./create-default-events');
var _createDefaultEvents2 = _interopRequireDefault(_createDefaultEvents);
var _bowser = require('bowser');
var _bowser2 = _interopRequireDefault(_bowser);
function _interopRequireWildcard(obj) { if (obj && obj.__esModule) { return obj; } else { var newObj = {}; if (obj != null) { for (var key in obj) { if (Object.prototype.hasOwnProperty.call(obj, key)) newObj[key] = obj[key]; } } newObj.default = obj; return newObj; } }
function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }
/**
* The Core module provides the Editable class that defines the Editable.JS
* API and is the main entry point for Editable.JS.
* It also provides the cursor module for cross-browser cursors, and the dom
* submodule.
*
* @module core
*/
/**
* Constructor for the Editable.JS API that is externally visible.
*
* @param {Object} configuration for this editable instance.
* window: The window where to attach the editable events.
* defaultBehavior: {Boolean} Load default-behavior.js.
* mouseMoveSelectionChanges: {Boolean} Whether to get cursor and selection events on mousemove.
* browserSpellcheck: {Boolean} Set the spellcheck attribute on editable elements
*
* @class Editable
*/
var Editable = module.exports = function () {
function Editable(instanceConfig) {
(0, _classCallCheck3.default)(this, Editable);
var defaultInstanceConfig = {
window: window,
defaultBehavior: true,
mouseMoveSelectionChanges: false,
browserSpellcheck: true
};
this.config = _jquery2.default.extend(defaultInstanceConfig, instanceConfig);
this.win = this.config.window;
this.editableSelector = '.' + config.editableClass;
if (!_rangy2.default.initialized) {
_rangy2.default.init();
}
this.dispatcher = new _dispatcher2.default(this);
if (this.config.defaultBehavior === true) {
this.dispatcher.on((0, _createDefaultEvents2.default)(this));
}
}
/**
* @returns the default Editable configs from config.js
*/
(0, _createClass3.default)(Editable, [{
key: 'add',
/**
* Adds the Editable.JS API to the given target elements.
* Opposite of {{#crossLink "Editable/remove"}}{{/crossLink}}.
* Calls dispatcher.setup to setup all event listeners.
*
* @method add
* @param {HTMLElement|Array(HTMLElement)|String} target A HTMLElement, an
* array of HTMLElement or a query selector representing the target where
* the API should be added on.
* @chainable
*/
value: function add(target) {
this.enable((0, _jquery2.default)(target));
// todo: check css whitespace settings
return this;
}
/**
* Removes the Editable.JS API from the given target elements.
* Opposite of {{#crossLink "Editable/add"}}{{/crossLink}}.
*
* @method remove
* @param {HTMLElement|Array(HTMLElement)|String} target A HTMLElement, an
* array of HTMLElement or a query selector representing the target where
* the API should be removed from.
* @chainable
*/
}, {
key: 'remove',
value: function remove(target) {
var $target = (0, _jquery2.default)(target);
this.disable($target);
$target.removeClass(config.editableDisabledClass);
return this;
}
/**
* Removes the Editable.JS API from the given target elements.
* The target elements are marked as disabled.
*
* @method disable
* @param { jQuery element | undefined } target editable root element(s)
* If no param is specified all editables are disabled.
* @chainable
*/
}, {
key: 'disable',
value: function disable($elem) {
var body = this.win.document.body;
$elem = $elem || (0, _jquery2.default)('.' + config.editableClass, body);
$elem.each(function (i, el) {
return block.disable(el);
});
return this;
}
/**
* Adds the Editable.JS API to the given target elements.
*
* @method enable
* @param { jQuery element | undefined } target editable root element(s)
* If no param is specified all editables marked as disabled are enabled.
* @chainable
*/
}, {
key: 'enable',
value: function enable($elem, normalize) {
var _this = this;
var body = this.win.document.body;
$elem = $elem || (0, _jquery2.default)('.' + config.editableDisabledClass, body);
var shouldSpellcheck = this.config.browserSpellcheck;
$elem.each(function (i, el) {
block.init(el, { normalize: normalize, shouldSpellcheck: shouldSpellcheck });
_this.dispatcher.notify('init', el);
});
return this;
}
/**
* Temporarily disable an editable.
* Can be used to prevent text selction while dragging an element
* for example.
*
* @method suspend
* @param jQuery object
*/
}, {
key: 'suspend',
value: function suspend($elem) {
var body = this.win.document.body;
$elem = $elem || (0, _jquery2.default)('.' + config.editableClass, body);
$elem.removeAttr('contenteditable');
this.dispatcher.suspend();
return this;
}
/**
* Reverse the effects of suspend()
*
* @method continue
* @param jQuery object
*/
}, {
key: 'continue',
value: function _continue($elem) {
var body = this.win.document.body;
$elem = $elem || (0, _jquery2.default)('.' + config.editableClass, body);
$elem.attr('contenteditable', true);
this.dispatcher.continue();
return this;
}
/**
* Set the cursor inside of an editable block.
*
* @method createCursor
* @param position 'beginning', 'end', 'before', 'after'
*/
}, {
key: 'createCursor',
value: function createCursor(element) {
var position = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : 'beginning';
var $host = (0, _jquery2.default)(element).closest(this.editableSelector);
if (!$host.length) return undefined;
var range = _rangy2.default.createRange();
if (position === 'beginning' || position === 'end') {
range.selectNodeContents(element);
range.collapse(position === 'beginning');
} else if (element !== $host[0]) {
if (position === 'before') {
range.setStartBefore(element);
range.setEndBefore(element);
} else if (position === 'after') {
range.setStartAfter(element);
range.setEndAfter(element);
}
} else {
(0, _error2.default)('EditableJS: cannot create cursor outside of an editable block.');
}
return new _cursor2.default($host[0], range);
}
}, {
key: 'createCursorAtBeginning',
value: function createCursorAtBeginning(element) {
return this.createCursor(element, 'beginning');
}
}, {
key: 'createCursorAtEnd',
value: function createCursorAtEnd(element) {
return this.createCursor(element, 'end');
}
}, {
key: 'createCursorBefore',
value: function createCursorBefore(element) {
return this.createCursor(element, 'before');
}
}, {
key: 'createCursorAfter',
value: function createCursorAfter(element) {
return this.createCursor(element, 'after');
}
/**
* Extract the content from an editable host or document fragment.
* This method will remove all internal elements and ui-elements.
*
* @param {DOM node or Document Fragment} The innerHTML of this element or fragment will be extracted.
* @returns {String} The cleaned innerHTML.
*/
}, {
key: 'getContent',
value: function getContent(element) {
return content.extractContent(element);
}
/**
* @param {String | DocumentFragment} content to append.
* @returns {Cursor} A new Cursor object just before the inserted content.
*/
}, {
key: 'appendTo',
value: function appendTo(inputElement, contentToAppend) {
var element = content.adoptElement(inputElement, this.win.document);
var cursor = this.createCursor(element, 'end');
// todo: create content in the right window
cursor.insertAfter(typeof contentToAppend === 'string' ? content.createFragmentFromString(contentToAppend) : contentToAppend);
return cursor;
}
/**
* @param {String | DocumentFragment} content to prepend
* @returns {Cursor} A new Cursor object just after the inserted content.
*/
}, {
key: 'prependTo',
value: function prependTo(inputElement, contentToPrepend) {
var element = content.adoptElement(inputElement, this.win.document);
var cursor = this.createCursor(element, 'beginning');
// todo: create content in the right window
cursor.insertBefore(typeof contentToPrepend === 'string' ? content.createFragmentFromString(contentToPrepend) : contentToPrepend);
return cursor;
}
/**
* Get the current selection.
* Only returns something if the selection is within an editable element.
* If you pass an editable host as param it only returns something if the selection is inside this
* very editable element.
*
* @param {DOMNode} Optional. An editable host where the selection needs to be contained.
* @returns A Cursor or Selection object or undefined.
*/
}, {
key: 'getSelection',
value: function getSelection(editableHost) {
var selection = this.dispatcher.selectionWatcher.getFreshSelection();
if (!(editableHost && selection)) return selection;
var range = selection.range;
// Check if the selection is inside the editableHost
// The try...catch is required if the editableHost was removed from the DOM.
try {
if (range.compareNode(editableHost) !== range.NODE_BEFORE_AND_AFTER) {
selection = undefined;
}
} catch (e) {
selection = undefined;
}
return selection;
}
/**
* Enable spellchecking
*
* @chainable
*/
}, {
key: 'setupHighlighting',
value: function setupHighlighting(hightlightingConfig) {
this.highlighting = new _highlighting2.default(this, hightlightingConfig);
return this;
}
// For backwards compatibility
}, {
key: 'setupSpellcheck',
value: function setupSpellcheck(config) {
var _this2 = this;
var marker = void 0;
if (config.markerNode) {
marker = config.markerNode.outerHTML;
}
this.setupHighlighting({
throttle: config.throttle,
spellcheck: {
marker: marker,
spellcheckService: config.spellcheckService
}
});
this.spellcheck = {
checkSpelling: function checkSpelling(elem) {
_this2.highlighting.highlight(elem);
}
};
}
/**
* Highlight text within an editable.
*
* By default highlights all occurences of `text`.
* Pass it a `textRange` object to highlight a
* specific text portion.
*
* The markup used for the highlighting will be removed
* from the final content.
*
*
* @param {Object} options
* @param {DOMNode} options.editableHost
* @param {String} options.text
* @param {String} options.highlightId Added to the highlight markups in the property `data-word-id`
* @param {Object} [options.textRange] An optional range which gets used to set the markers.
* @param {Number} options.textRange.start
* @param {Number} options.textRange.end
* @param {Boolean} options.raiseEvents do throw change events
* @return {Number} The text-based start offset of the newly applied highlight or `-1` if the range was considered invalid.
*/
}, {
key: 'highlight',
value: function highlight(_ref) {
var editableHost = _ref.editableHost,
text = _ref.text,
highlightId = _ref.highlightId,
textRange = _ref.textRange,
raiseEvents = _ref.raiseEvents;
if (!textRange) {
return _highlightSupport2.default.highlightText(editableHost, text, highlightId);
}
if (typeof textRange.start !== 'number' || typeof textRange.end !== 'number') {
(0, _error2.default)('Error in Editable.highlight: You passed a textRange object with invalid keys. Expected shape: { start: Number, end: Number }');
return -1;
}
if (textRange.start === textRange.end) {
(0, _error2.default)('Error in Editable.highlight: You passed a textRange object with equal start and end offsets, which is considered a cursor and therefore unfit to create a highlight.');
return -1;
}
return _highlightSupport2.default.highlightRange(editableHost, highlightId, textRange.start, textRange.end, raiseEvents ? this.dispatcher : undefined);
}
/**
* Extracts positions of all DOMNodes that match `[data-word-id]`.
*
* Returns an object where the keys represent a highlight id and the value
* a text range object of shape:
* ```
* { start: number, end: number, text: string}
* ```
*
* @param {Object} options
* @param {DOMNode} options.editableHos
* @return {Object} ranges
*/
}, {
key: 'getHighlightPositions',
value: function getHighlightPositions(_ref2) {
var editableHost = _ref2.editableHost;
return _highlightSupport2.default.extractHighlightedRanges(editableHost);
}
}, {
key: 'removeHighlight',
value: function removeHighlight(_ref3) {
var editableHost = _ref3.editableHost,
highlightId = _ref3.highlightId,
raiseEvents = _ref3.raiseEvents;
_highlightSupport2.default.removeHighlight(editableHost, highlightId, raiseEvents ? this.dispatcher : undefined);
}
}, {
key: 'decorateHighlight',
value: function decorateHighlight(_ref4) {
var editableHost = _ref4.editableHost,
highlightId = _ref4.highlightId,
addCssClass = _ref4.addCssClass,
removeCssClass = _ref4.removeCssClass;
_highlightSupport2.default.updateHighlight(editableHost, highlightId, addCssClass, removeCssClass);
}
/**
* Subscribe a callback function to a custom event fired by the API.
*
* @param {String} event The name of the event.
* @param {Function} handler The callback to execute in response to the
* event.
*
* @chainable
*/
}, {
key: 'on',
value: function on(event, handler) {
// TODO throw error if event is not one of EVENTS
// TODO throw error if handler is not a function
this.dispatcher.on(event, handler);
return this;
}
/**
* Unsubscribe a callback function from a custom event fired by the API.
* Opposite of {{#crossLink "Editable/on"}}{{/crossLink}}.
*
* @param {String} event The name of the event.
* @param {Function} handler The callback to remove from the
* event or the special value false to remove all callbacks.
*
* @chainable
*/
}, {
key: 'off',
value: function off() {
for (var _len = arguments.length, args = Array(_len), _key = 0; _key < _len; _key++) {
args[_key] = arguments[_key];
}
this.dispatcher.off.apply(this.dispatcher, args);
return this;
}
/**
* Unsubscribe all callbacks and event listeners.
*
* @chainable
*/
}, {
key: 'unload',
value: function unload() {
this.dispatcher.unload();
return this;
}
}], [{
key: 'getGlobalConfig',
value: function getGlobalConfig() {
return config;
}
/**
* Set configuration options that affect all editable
* instances.
*
* @param {Object} global configuration options (defaults are defined in config.js)
* log: {Boolean}
* logErrors: {Boolean}
* editableClass: {String} e.g. 'js-editable'
* editableDisabledClass: {String} e.g. 'js-editable-disabled'
* pastingAttribute: {String} default: e.g. 'data-editable-is-pasting'
* boldTag: e.g. '<strong>'
* italicTag: e.g. '<em>'
*/
}, {
key: 'globalConfig',
value: function globalConfig(_globalConfig) {
_jquery2.default.extend(config, _globalConfig);
clipboard.updateConfig(config);
}
}]);
return Editable;
}();
// Expose modules and editable
Editable.parser = parser;
Editable.content = content;
Editable.browser = _bowser2.default
// Set up callback functions for several events.
;['focus', 'blur', 'flow', 'selection', 'cursor', 'newline', 'insert', 'split', 'merge', 'empty', 'change', 'switch', 'move', 'clipboard', 'paste'].forEach(function (name) {
// Generate a callback function to subscribe to an event.
Editable.prototype[name] = function (handler) {
return this.on(name, handler);
};
});