upfront-editable
Version:
Friendly contenteditable API
690 lines (596 loc) • 25.1 kB
JavaScript
"use strict";
var _interopRequireDefault = require("@babel/runtime/helpers/interopRequireDefault");
var _typeof = require("@babel/runtime/helpers/typeof");
Object.defineProperty(exports, "__esModule", {
value: true
});
exports["default"] = void 0;
var _classCallCheck2 = _interopRequireDefault(require("@babel/runtime/helpers/classCallCheck"));
var _createClass2 = _interopRequireDefault(require("@babel/runtime/helpers/createClass"));
var _rangy = _interopRequireDefault(require("rangy"));
var _config = _interopRequireDefault(require("./config"));
var _error = _interopRequireDefault(require("./util/error"));
var parser = _interopRequireWildcard(require("./parser"));
var block = _interopRequireWildcard(require("./block"));
var content = _interopRequireWildcard(require("./content"));
var clipboard = _interopRequireWildcard(require("./clipboard"));
var _dispatcher = _interopRequireDefault(require("./dispatcher"));
var _cursor = _interopRequireDefault(require("./cursor"));
var _highlightSupport = _interopRequireDefault(require("./highlight-support"));
var _highlighting = _interopRequireDefault(require("./highlighting"));
var _createDefaultEvents = _interopRequireDefault(require("./create-default-events"));
var _bowser = _interopRequireDefault(require("bowser"));
var _element = require("./util/element");
var _binary_search = require("./util/binary_search");
var _dom = require("./util/dom");
function _getRequireWildcardCache(nodeInterop) { if (typeof WeakMap !== "function") return null; var cacheBabelInterop = new WeakMap(); var cacheNodeInterop = new WeakMap(); return (_getRequireWildcardCache = function _getRequireWildcardCache(nodeInterop) { return nodeInterop ? cacheNodeInterop : cacheBabelInterop; })(nodeInterop); }
function _interopRequireWildcard(obj, nodeInterop) { if (!nodeInterop && obj && obj.__esModule) { return obj; } if (obj === null || _typeof(obj) !== "object" && typeof obj !== "function") { return { "default": obj }; } var cache = _getRequireWildcardCache(nodeInterop); if (cache && cache.has(obj)) { return cache.get(obj); } var newObj = {}; var hasPropertyDescriptor = Object.defineProperty && Object.getOwnPropertyDescriptor; for (var key in obj) { if (key !== "default" && Object.prototype.hasOwnProperty.call(obj, key)) { var desc = hasPropertyDescriptor ? Object.getOwnPropertyDescriptor(obj, key) : null; if (desc && (desc.get || desc.set)) { Object.defineProperty(newObj, key, desc); } else { newObj[key] = obj[key]; } } } newObj["default"] = obj; if (cache) { cache.set(obj, newObj); } return newObj; }
function _createForOfIteratorHelper(o, allowArrayLike) { var it = typeof Symbol !== "undefined" && o[Symbol.iterator] || o["@@iterator"]; if (!it) { if (Array.isArray(o) || (it = _unsupportedIterableToArray(o)) || allowArrayLike && o && typeof o.length === "number") { if (it) o = it; var i = 0; var F = function F() {}; return { s: F, n: function n() { if (i >= o.length) return { done: true }; return { done: false, value: o[i++] }; }, e: function e(_e) { throw _e; }, f: F }; } throw new TypeError("Invalid attempt to iterate non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method."); } var normalCompletion = true, didErr = false, err; return { s: function s() { it = it.call(o); }, n: function n() { var step = it.next(); normalCompletion = step.done; return step; }, e: function e(_e2) { didErr = true; err = _e2; }, f: function f() { try { if (!normalCompletion && it["return"] != null) it["return"](); } finally { if (didErr) throw err; } } }; }
function _unsupportedIterableToArray(o, minLen) { if (!o) return; if (typeof o === "string") return _arrayLikeToArray(o, minLen); var n = Object.prototype.toString.call(o).slice(8, -1); if (n === "Object" && o.constructor) n = o.constructor.name; if (n === "Map" || n === "Set") return Array.from(o); if (n === "Arguments" || /^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(n)) return _arrayLikeToArray(o, minLen); }
function _arrayLikeToArray(arr, len) { if (len == null || len > arr.length) len = arr.length; for (var i = 0, arr2 = new Array(len); i < len; i++) { arr2[i] = arr[i]; } return arr2; }
/**
* 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 = /*#__PURE__*/function () {
function Editable(instanceConfig) {
(0, _classCallCheck2["default"])(this, Editable);
var defaultInstanceConfig = {
window: window,
defaultBehavior: true,
mouseMoveSelectionChanges: false,
browserSpellcheck: true
};
this.config = Object.assign(defaultInstanceConfig, instanceConfig);
this.win = this.config.window;
this.editableSelector = ".".concat(_config["default"].editableClass);
if (!_rangy["default"].initialized) {
_rangy["default"].init();
}
this.dispatcher = new _dispatcher["default"](this);
if (this.config.defaultBehavior === true) {
this.dispatcher.on((0, _createDefaultEvents["default"])(this));
}
}
/**
* @returns the default Editable configs from config.js
*/
(0, _createClass2["default"])(Editable, [{
key: "add",
value:
/**
* 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
*/
function add(target) {
this.enable(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 targets = (0, _dom.domArray)(target, this.win.document);
this.disable(targets);
var _iterator = _createForOfIteratorHelper(targets),
_step;
try {
for (_iterator.s(); !(_step = _iterator.n()).done;) {
var element = _step.value;
element.classList.remove(_config["default"].editableDisabledClass);
}
} catch (err) {
_iterator.e(err);
} finally {
_iterator.f();
}
return this;
}
/**
* Removes the Editable.JS API from the given target elements.
* The target elements are marked as disabled.
*
* @method disable
* @param { HTMLElement | undefined } elem editable root element(s)
* If no param is specified all editables are disabled.
* @chainable
*/
}, {
key: "disable",
value: function disable(target) {
var targets = (0, _dom.domArray)(target || ".".concat(_config["default"].editableClass), this.win.document);
var _iterator2 = _createForOfIteratorHelper(targets),
_step2;
try {
for (_iterator2.s(); !(_step2 = _iterator2.n()).done;) {
var element = _step2.value;
block.disable(element);
}
} catch (err) {
_iterator2.e(err);
} finally {
_iterator2.f();
}
return this;
}
/**
* Adds the Editable.JS API to the given target elements.
*
* @method enable
* @param { HTMLElement | undefined } target editable root element(s)
* If no param is specified all editables marked as disabled are enabled.
* @chainable
*/
}, {
key: "enable",
value: function enable(target, normalize) {
var shouldSpellcheck = this.config.browserSpellcheck;
var targets = (0, _dom.domArray)(target || ".".concat(_config["default"].editableDisabledClass), this.win.document);
var _iterator3 = _createForOfIteratorHelper(targets),
_step3;
try {
for (_iterator3.s(); !(_step3 = _iterator3.n()).done;) {
var element = _step3.value;
block.init(element, {
normalize: normalize,
shouldSpellcheck: shouldSpellcheck
});
this.dispatcher.notify('init', element);
}
} catch (err) {
_iterator3.e(err);
} finally {
_iterator3.f();
}
return this;
}
/**
* Temporarily disable an editable.
* Can be used to prevent text selection while dragging an element
* for example.
*
* @method suspend
* @param { HTMLElement | undefined } target
*/
}, {
key: "suspend",
value: function suspend(target) {
var targets = (0, _dom.domArray)(target || ".".concat(_config["default"].editableClass), this.win.document);
var _iterator4 = _createForOfIteratorHelper(targets),
_step4;
try {
for (_iterator4.s(); !(_step4 = _iterator4.n()).done;) {
var element = _step4.value;
element.removeAttribute('contenteditable');
}
} catch (err) {
_iterator4.e(err);
} finally {
_iterator4.f();
}
this.dispatcher.suspend();
return this;
}
/**
* Reverse the effects of suspend()
*
* @method continue
* @param { HTMLElement | undefined } target
*/
}, {
key: "continue",
value: function _continue(target) {
var targets = (0, _dom.domArray)(target || ".".concat(_config["default"].editableClass), this.win.document);
var _iterator5 = _createForOfIteratorHelper(targets),
_step5;
try {
for (_iterator5.s(); !(_step5 = _iterator5.n()).done;) {
var element = _step5.value;
element.setAttribute('contenteditable', true);
}
} catch (err) {
_iterator5.e(err);
} finally {
_iterator5.f();
}
this.dispatcher["continue"]();
return this;
}
/**
* Set the cursor inside of an editable block.
*
* @method createCursor
* @param { HTMLElement } element
* @param { 'beginning' | 'end' | 'before' | 'after' } position
*/
}, {
key: "createCursor",
value: function createCursor(element) {
var position = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : 'beginning';
var host = _cursor["default"].findHost(element, this.editableSelector);
if (!host) return undefined;
var range = _rangy["default"].createRange();
if (position === 'beginning' || position === 'end') {
range.selectNodeContents(element);
range.collapse(position === 'beginning');
} else if (element !== host) {
if (position === 'before') {
range.setStartBefore(element);
range.setEndBefore(element);
} else if (position === 'after') {
range.setStartAfter(element);
range.setEndAfter(element);
}
} else {
(0, _error["default"])('EditableJS: cannot create cursor outside of an editable block.');
}
return new _cursor["default"](host, range);
}
}, {
key: "createCursorAtCharacterOffset",
value: function createCursorAtCharacterOffset(_ref) {
var element = _ref.element,
offset = _ref.offset;
var textNodes = (0, _element.textNodesUnder)(element);
var _getTextNodeAndRelati = (0, _element.getTextNodeAndRelativeOffset)({
textNodes: textNodes,
absOffset: offset
}),
node = _getTextNodeAndRelati.node,
relativeOffset = _getTextNodeAndRelati.relativeOffset;
var newRange = _rangy["default"].createRange();
newRange.setStart(node, relativeOffset);
newRange.collapse(true);
var host = _cursor["default"].findHost(element, this.editableSelector);
var nextCursor = new _cursor["default"](host, newRange);
nextCursor.setVisibleSelection();
return nextCursor;
}
}, {
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 _highlighting["default"](this, hightlightingConfig);
return this;
} // For backwards compatibility
}, {
key: "setupSpellcheck",
value: function setupSpellcheck(conf) {
var _this = this;
var marker;
if (conf.markerNode) {
marker = conf.markerNode.outerHTML;
}
this.setupHighlighting({
throttle: conf.throttle,
spellcheck: {
marker: marker,
spellcheckService: conf.spellcheckService
}
});
this.spellcheck = {
checkSpelling: function checkSpelling(elem) {
_this.highlighting.highlight(elem);
}
};
}
/**
* Highlight text within an editable.
*
* By default highlights all occurrences 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(_ref2) {
var editableHost = _ref2.editableHost,
text = _ref2.text,
highlightId = _ref2.highlightId,
textRange = _ref2.textRange,
raiseEvents = _ref2.raiseEvents;
if (!textRange) {
return _highlightSupport["default"].highlightText(editableHost, text, highlightId);
}
if (typeof textRange.start !== 'number' || typeof textRange.end !== 'number') {
(0, _error["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, _error["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 _highlightSupport["default"].highlightRange(editableHost, highlightId, textRange.start, textRange.end, raiseEvents ? this.dispatcher : undefined);
}
/**
* Extracts positions of all DOMNodes that match `[data-word-id]` and the `[data-highlight]`
*
* 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.editableHost
* @param {String} [options.type]
* @return {Object} ranges
*/
}, {
key: "getHighlightPositions",
value: function getHighlightPositions(_ref3) {
var editableHost = _ref3.editableHost,
type = _ref3.type;
return _highlightSupport["default"].extractHighlightedRanges(editableHost, type);
}
}, {
key: "removeHighlight",
value: function removeHighlight(_ref4) {
var editableHost = _ref4.editableHost,
highlightId = _ref4.highlightId,
raiseEvents = _ref4.raiseEvents;
_highlightSupport["default"].removeHighlight(editableHost, highlightId, raiseEvents ? this.dispatcher : undefined);
}
}, {
key: "decorateHighlight",
value: function decorateHighlight(_ref5) {
var editableHost = _ref5.editableHost,
highlightId = _ref5.highlightId,
addCssClass = _ref5.addCssClass,
removeCssClass = _ref5.removeCssClass;
_highlightSupport["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 = new 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;
}
/**
* Takes coordinates and uses its left value to find out how to offset a character in a string to
* closely match the coordinates.left value.
* Takes conditions for the result being on the first line, used when navigating to a paragraph from
* the above paragraph and being on the last line, used when navigating to a paragraph from the below
* paragraph.
*
* Internally this sets up the methods used for a binary cursor search and calls this.
*
* @param {DomNode} element
* - the editable hostDOM Node to which the cursor jumps
* @param {object} coordinates
* - The bounding rect of the preceding cursor to be matched
* @param {boolean} requiredOnFirstLine
* - set to true if you want to require the cursor to be on the first line of the paragraph
* @param {boolean} requiredOnLastLine
* - set to true if you want to require the cursor to be on the last line of the paragraph
*
* @return {Object}
* - object with boolean `wasFound` indicating if the binary search found an offset and `offset` to indicate the actual character offset
*/
}, {
key: "findClosestCursorOffset",
value: function findClosestCursorOffset(_ref6) {
var element = _ref6.element,
origCoordinates = _ref6.origCoordinates,
_ref6$requiredOnFirst = _ref6.requiredOnFirstLine,
requiredOnFirstLine = _ref6$requiredOnFirst === void 0 ? false : _ref6$requiredOnFirst,
_ref6$requiredOnLastL = _ref6.requiredOnLastLine,
requiredOnLastLine = _ref6$requiredOnLastL === void 0 ? false : _ref6$requiredOnLastL;
var positionX = this.dispatcher.switchContext ? this.dispatcher.switchContext.positionX : origCoordinates.left;
return (0, _binary_search.binaryCursorSearch)({
host: element,
requiredOnFirstLine: requiredOnFirstLine,
requiredOnLastLine: requiredOnLastLine,
positionX: positionX
});
}
}], [{
key: "getGlobalConfig",
value: function getGlobalConfig() {
return _config["default"];
}
/**
* 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) {
Object.assign(_config["default"], _globalConfig);
clipboard.updateConfig(_config["default"]);
}
}]);
return Editable;
}(); // Expose modules and editable
exports["default"] = Editable;
Editable.parser = parser;
Editable.content = content;
Editable.browser = _bowser["default"] // Set up callback functions for several events.
;
['focus', 'blur', 'flow', 'selection', 'cursor', 'newline', 'insert', 'split', 'merge', 'empty', 'change', 'switch', 'move', 'clipboard', 'paste', 'spellcheckUpdated'].forEach(function (name) {
// Generate a callback function to subscribe to an event.
Editable.prototype[name] = function (handler) {
return this.on(name, handler);
};
});
module.exports = exports.default;