UNPKG

upfront-editable

Version:
690 lines (596 loc) 25.1 kB
"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;