UNPKG

medium-draft

Version:

A medium like rich text editor built upon draft-js with an emphasis on eliminating mouse usage by adding relevant keyboard shortcuts

683 lines (592 loc) 27 kB
'use strict'; Object.defineProperty(exports, "__esModule", { value: true }); var _extends = Object.assign || function (target) { for (var i = 1; i < arguments.length; i++) { var source = arguments[i]; for (var key in source) { if (Object.prototype.hasOwnProperty.call(source, key)) { target[key] = source[key]; } } } return target; }; var _createClass = function () { function defineProperties(target, props) { for (var i = 0; i < props.length; i++) { var descriptor = props[i]; descriptor.enumerable = descriptor.enumerable || false; descriptor.configurable = true; if ("value" in descriptor) descriptor.writable = true; Object.defineProperty(target, descriptor.key, descriptor); } } return function (Constructor, protoProps, staticProps) { if (protoProps) defineProperties(Constructor.prototype, protoProps); if (staticProps) defineProperties(Constructor, staticProps); return Constructor; }; }(); var _propTypes = require('prop-types'); var _propTypes2 = _interopRequireDefault(_propTypes); var _react = require('react'); var _react2 = _interopRequireDefault(_react); var _draftJs = require('draft-js'); var _isSoftNewlineEvent = require('draft-js/lib/isSoftNewlineEvent'); var _isSoftNewlineEvent2 = _interopRequireDefault(_isSoftNewlineEvent); var _immutable = require('immutable'); var _addbutton = require('./components/addbutton'); var _addbutton2 = _interopRequireDefault(_addbutton); var _toolbar = require('./components/toolbar'); var _toolbar2 = _interopRequireDefault(_toolbar); var _LinkEditComponent = require('./components/LinkEditComponent'); var _LinkEditComponent2 = _interopRequireDefault(_LinkEditComponent); var _customrenderer = require('./components/customrenderer'); var _customrenderer2 = _interopRequireDefault(_customrenderer); var _customstylemap = require('./util/customstylemap'); var _customstylemap2 = _interopRequireDefault(_customstylemap); var _rendermap = require('./util/rendermap'); var _rendermap2 = _interopRequireDefault(_rendermap); var _keybinding = require('./util/keybinding'); var _keybinding2 = _interopRequireDefault(_keybinding); var _constants = require('./util/constants'); var _beforeinput = require('./util/beforeinput'); var _beforeinput2 = _interopRequireDefault(_beforeinput); var _blockStyleFn = require('./util/blockStyleFn'); var _blockStyleFn2 = _interopRequireDefault(_blockStyleFn); var _model = require('./model'); var _image = require('./components/sides/image'); var _image2 = _interopRequireDefault(_image); function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } function _classCallCheck(instance, Constructor) { if (!(instance instanceof Constructor)) { throw new TypeError("Cannot call a class as a function"); } } function _possibleConstructorReturn(self, call) { if (!self) { throw new ReferenceError("this hasn't been initialised - super() hasn't been called"); } return call && (typeof call === "object" || typeof call === "function") ? call : self; } function _inherits(subClass, superClass) { if (typeof superClass !== "function" && superClass !== null) { throw new TypeError("Super expression must either be null or a function, not " + typeof superClass); } subClass.prototype = Object.create(superClass && superClass.prototype, { constructor: { value: subClass, enumerable: false, writable: true, configurable: true } }); if (superClass) Object.setPrototypeOf ? Object.setPrototypeOf(subClass, superClass) : subClass.__proto__ = superClass; } /* A wrapper over `draft-js`'s default **Editor** component which provides some built-in customisations like custom blocks (todo, caption, etc) and some key handling for ease of use so that users' mouse usage is minimum. */ var MediumDraftEditor = function (_React$Component) { _inherits(MediumDraftEditor, _React$Component); function MediumDraftEditor(props) { _classCallCheck(this, MediumDraftEditor); var _this = _possibleConstructorReturn(this, (MediumDraftEditor.__proto__ || Object.getPrototypeOf(MediumDraftEditor)).call(this, props)); _this.onUpArrow = function (e) { if (_this.props.onUpArrow) { _this.props.onUpArrow(e); return; } var editorState = _this.props.editorState; var content = editorState.getCurrentContent(); var selection = editorState.getSelection(); var key = selection.getAnchorKey(); var currentBlock = content.getBlockForKey(key); var firstBlock = content.getFirstBlock(); if (firstBlock.getKey() === key) { if (firstBlock.getType().indexOf(_constants.Block.ATOMIC) === 0) { e.preventDefault(); var newBlock = new _draftJs.ContentBlock({ type: _constants.Block.UNSTYLED, key: (0, _draftJs.genKey)() }); var newBlockMap = (0, _immutable.OrderedMap)([[newBlock.getKey(), newBlock]]).concat(content.getBlockMap()); var newContent = content.merge({ blockMap: newBlockMap, selectionAfter: selection.merge({ anchorKey: newBlock.getKey(), focusKey: newBlock.getKey(), anchorOffset: 0, focusOffset: 0, isBackward: false }) }); _this.onChange(_draftJs.EditorState.push(editorState, newContent, 'insert-characters')); } } else if (currentBlock.getType().indexOf(_constants.Block.ATOMIC) === 0) { var blockBefore = content.getBlockBefore(key); if (!blockBefore) { return; } e.preventDefault(); var newSelection = selection.merge({ anchorKey: blockBefore.getKey(), focusKey: blockBefore.getKey(), anchorOffset: blockBefore.getLength(), focusOffset: blockBefore.getLength(), isBackward: false }); _this.onChange(_draftJs.EditorState.forceSelection(editorState, newSelection)); } }; _this.onDownArrow = function (e) { if (_this.props.onDownArrow) { _this.props.onDownArrow(e); } }; _this.removeLink = function (blockKey, entityKey) { var editorState = _this.props.editorState; var content = editorState.getCurrentContent(); var block = content.getBlockForKey(blockKey); var oldSelection = editorState.getSelection(); block.findEntityRanges(function (character) { var eKey = character.getEntity(); return eKey === entityKey; }, function (start, end) { var selection = new _draftJs.SelectionState({ anchorKey: blockKey, focusKey: blockKey, anchorOffset: start, focusOffset: end }); var newEditorState = _draftJs.EditorState.forceSelection(_draftJs.RichUtils.toggleLink(editorState, selection, null), oldSelection); _this.onChange(newEditorState, _this.focus); }); }; _this.editLinkAfterSelection = function (blockKey) { var entityKey = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : null; if (entityKey === null) { return; } var editorState = _this.props.editorState; var content = editorState.getCurrentContent(); var block = content.getBlockForKey(blockKey); block.findEntityRanges(function (character) { var eKey = character.getEntity(); return eKey === entityKey; }, function (start, end) { var selection = new _draftJs.SelectionState({ anchorKey: blockKey, focusKey: blockKey, anchorOffset: start, focusOffset: end }); var newEditorState = _draftJs.EditorState.forceSelection(editorState, selection); _this.onChange(newEditorState); setTimeout(function () { if (_this.toolbar) { _this.toolbar.handleLinkInput(null, true); } }, 100); }); }; _this.handlePastedText = function (text, html, es) { var currentBlock = (0, _model.getCurrentBlock)(_this.props.editorState); if (currentBlock.getType() === _constants.Block.IMAGE) { var editorState = _this.props.editorState; var content = editorState.getCurrentContent(); _this.onChange(_draftJs.EditorState.push(editorState, _draftJs.Modifier.insertText(content, editorState.getSelection(), text))); return _constants.HANDLED; } if (_this.props.handlePastedText && _this.props.handlePastedText(text, html, es) === _constants.HANDLED) { return _constants.HANDLED; } return _constants.NOT_HANDLED; }; _this.focus = function () { return _this._editorNode.focus(); }; _this.onChange = function (editorState, cb) { _this.props.onChange(editorState, cb); }; _this.getEditorState = function () { return _this.props.editorState; }; _this.onTab = _this.onTab.bind(_this); _this.onUpArrow = _this.onUpArrow.bind(_this); _this.onDownArrow = _this.onDownArrow.bind(_this); _this.handleKeyCommand = _this.handleKeyCommand.bind(_this); _this.handleBeforeInput = _this.handleBeforeInput.bind(_this); _this.handleReturn = _this.handleReturn.bind(_this); _this.toggleBlockType = _this._toggleBlockType.bind(_this); _this.toggleInlineStyle = _this._toggleInlineStyle.bind(_this); _this.setLink = _this.setLink.bind(_this); _this.blockRendererFn = _this.props.rendererFn(_this.onChange, _this.getEditorState, _this.props); return _this; } /** * Implemented to provide nesting of upto 2 levels in ULs or OLs. */ _createClass(MediumDraftEditor, [{ key: 'onTab', value: function onTab(e) { if (this.props.onTab) { this.props.onTab(e); return; } var editorState = this.props.editorState; var newEditorState = _draftJs.RichUtils.onTab(e, editorState, 2); if (newEditorState !== editorState) { this.onChange(newEditorState); } } }, { key: 'setLink', /* Adds a hyperlink on the selected text with some basic checks. */ value: function setLink(url) { var editorState = this.props.editorState; var selection = editorState.getSelection(); var content = editorState.getCurrentContent(); var entityKey = null; var newUrl = url; if (this.props.processURL) { newUrl = this.props.processURL(url); } else if (url.indexOf('http') !== 0 && url.indexOf('mailto:') !== 0) { if (url.indexOf('@') >= 0) { newUrl = 'mailto:' + newUrl; } else { newUrl = 'http://' + newUrl; } } if (newUrl !== '') { var contentWithEntity = content.createEntity(_constants.Entity.LINK, 'MUTABLE', { url: newUrl }); editorState = _draftJs.EditorState.push(editorState, contentWithEntity, 'create-entity'); entityKey = contentWithEntity.getLastCreatedEntityKey(); } this.onChange(_draftJs.RichUtils.toggleLink(editorState, selection, entityKey), this.focus); } /** * Override which text modifications are available according BLOCK_BUTTONS style property. * Defaults all of them if no toolbarConfig.block passed: * block: ['ordered-list-item', 'unordered-list-item', 'blockquote', 'header-three', 'todo'], * Example parameter: toolbarConfig = { * block: ['ordered-list-item', 'unordered-list-item'], * inline: ['BOLD', 'ITALIC', 'UNDERLINE', 'hyperlink'], * }; */ }, { key: 'configureToolbarBlockOptions', value: function configureToolbarBlockOptions(toolbarConfig) { var blockButtons = this.props.blockButtons; return toolbarConfig && toolbarConfig.block ? toolbarConfig.block.map(function (type) { return blockButtons.find(function (button) { return button.style === type; }); }).filter(function (button) { return button !== undefined; }) : blockButtons; } /** * Override which text modifications are available according INLINE_BUTTONS style property. * CASE SENSITIVE. Would be good clean up to lowercase inline styles consistently. * Defaults all of them if no toolbarConfig.inline passed: * inline: ['BOLD', 'ITALIC', 'UNDERLINE', 'hyperlink', 'HIGHLIGHT'], * Example parameter: toolbarConfig = { * block: ['ordered-list-item', 'unordered-list-item'], * inline: ['BOLD', 'ITALIC', 'UNDERLINE', 'hyperlink'], * }; */ }, { key: 'configureToolbarInlineOptions', value: function configureToolbarInlineOptions(toolbarConfig) { var inlineButtons = this.props.inlineButtons; return toolbarConfig && toolbarConfig.inline ? toolbarConfig.inline.map(function (type) { return inlineButtons.find(function (button) { return button.style === type; }); }).filter(function (button) { return button !== undefined; }) : inlineButtons; } /* Handles custom commands based on various key combinations. First checks for some built-in commands. If found, that command's function is apllied and returns. If not found, it checks whether parent component handles that command or not. Some of the internal commands are: - showlinkinput -> Opens up the link input tooltip if some text is selected. - add-new-block -> Adds a new block at the current cursor position. - changetype:block-type -> If the command starts with `changetype:` and then succeeded by the block type, the current block will be converted to that particular type. - toggleinline:inline-type -> If the command starts with `toggleinline:` and then succeeded by the inline type, the current selection's inline type will be togglled. */ }, { key: 'handleKeyCommand', value: function handleKeyCommand(command) { // console.log(command); var editorState = this.props.editorState; if (this.props.handleKeyCommand) { var behaviour = this.props.handleKeyCommand(command); if (behaviour === _constants.HANDLED || behaviour === true) { return _constants.HANDLED; } } if (command === _constants.KEY_COMMANDS.showLinkInput()) { if (!this.props.disableToolbar && this.toolbar) { // For some reason, scroll is jumping sometimes for the below code. // Debug and fix it later. var isCursorLink = (0, _model.isCursorBetweenLink)(editorState); if (isCursorLink) { this.editLinkAfterSelection(isCursorLink.blockKey, isCursorLink.entityKey); return _constants.HANDLED; } this.toolbar.handleLinkInput(null, true); return _constants.HANDLED; } return _constants.NOT_HANDLED; } else if (command === _constants.KEY_COMMANDS.unlink()) { var _isCursorLink = (0, _model.isCursorBetweenLink)(editorState); if (_isCursorLink) { this.removeLink(_isCursorLink.blockKey, _isCursorLink.entityKey); return _constants.HANDLED; } } /* else if (command === KEY_COMMANDS.addNewBlock()) { const { editorState } = this.props; this.onChange(addNewBlock(editorState, Block.BLOCKQUOTE)); return HANDLED; } */ var block = (0, _model.getCurrentBlock)(editorState); var currentBlockType = block.getType(); // if (command === KEY_COMMANDS.deleteBlock()) { // if (currentBlockType.indexOf(Block.ATOMIC) === 0 && block.getText().length === 0) { // this.onChange(resetBlockWithType(editorState, Block.UNSTYLED, { text: '' })); // return HANDLED; // } // return NOT_HANDLED; // } if (command.indexOf('' + _constants.KEY_COMMANDS.changeType()) === 0) { var newBlockType = command.split(':')[1]; // const currentBlockType = block.getType(); if (currentBlockType === _constants.Block.ATOMIC) { return _constants.HANDLED; } if (currentBlockType === _constants.Block.BLOCKQUOTE && newBlockType === _constants.Block.CAPTION) { newBlockType = _constants.Block.BLOCKQUOTE_CAPTION; } else if (currentBlockType === _constants.Block.BLOCKQUOTE_CAPTION && newBlockType === _constants.Block.CAPTION) { newBlockType = _constants.Block.BLOCKQUOTE; } this.onChange(_draftJs.RichUtils.toggleBlockType(editorState, newBlockType)); return _constants.HANDLED; } else if (command.indexOf('' + _constants.KEY_COMMANDS.toggleInline()) === 0) { var inline = command.split(':')[1]; this._toggleInlineStyle(inline); return _constants.HANDLED; } var newState = _draftJs.RichUtils.handleKeyCommand(editorState, command); if (newState) { this.onChange(newState); return _constants.HANDLED; } return _constants.NOT_HANDLED; } /* This function is responsible for emitting various commands based on various key combos. */ }, { key: 'handleBeforeInput', value: function handleBeforeInput(str) { return this.props.beforeInput(this.props.editorState, str, this.onChange, this.props.stringToTypeMap); } /* By default, it handles return key for inserting soft breaks (BRs in HTML) and also instead of inserting a new empty block after current empty block, it first check whether the current block is of a type other than `unstyled`. If yes, current block is simply converted to an unstyled empty block. If RETURN is pressed on an unstyled block default behavior is executed. */ }, { key: 'handleReturn', value: function handleReturn(e) { if (this.props.handleReturn) { var behavior = this.props.handleReturn(e); if (behavior === _constants.HANDLED || behavior === true) { return _constants.HANDLED; } } var editorState = this.props.editorState; if ((0, _isSoftNewlineEvent2.default)(e)) { this.onChange(_draftJs.RichUtils.insertSoftNewline(editorState)); return _constants.HANDLED; } if (!e.altKey && !e.metaKey && !e.ctrlKey) { var currentBlock = (0, _model.getCurrentBlock)(editorState); var blockType = currentBlock.getType(); if (blockType.indexOf(_constants.Block.ATOMIC) === 0) { this.onChange((0, _model.addNewBlockAt)(editorState, currentBlock.getKey())); return _constants.HANDLED; } if (currentBlock.getLength() === 0) { switch (blockType) { case _constants.Block.UL: case _constants.Block.OL: case _constants.Block.BLOCKQUOTE: case _constants.Block.BLOCKQUOTE_CAPTION: case _constants.Block.CAPTION: case _constants.Block.TODO: case _constants.Block.H2: case _constants.Block.H3: case _constants.Block.H1: this.onChange((0, _model.resetBlockWithType)(editorState, _constants.Block.UNSTYLED)); return _constants.HANDLED; default: return _constants.NOT_HANDLED; } } var selection = editorState.getSelection(); if (selection.isCollapsed() && currentBlock.getLength() === selection.getStartOffset()) { if (this.props.continuousBlocks.indexOf(blockType) < 0) { this.onChange((0, _model.addNewBlockAt)(editorState, currentBlock.getKey())); return _constants.HANDLED; } return _constants.NOT_HANDLED; } return _constants.NOT_HANDLED; } return _constants.NOT_HANDLED; } /* The function documented in `draft-js` to be used to toggle block types (mainly for some key combinations handled by default inside draft-js). */ }, { key: '_toggleBlockType', value: function _toggleBlockType(blockType) { var type = _draftJs.RichUtils.getCurrentBlockType(this.props.editorState); if (type.indexOf(_constants.Block.ATOMIC + ':') === 0) { return; } this.onChange(_draftJs.RichUtils.toggleBlockType(this.props.editorState, blockType)); } /* The function documented in `draft-js` to be used to toggle inline styles of selection (mainly for some key combinations handled by default inside draft-js). */ }, { key: '_toggleInlineStyle', value: function _toggleInlineStyle(inlineStyle) { this.onChange(_draftJs.RichUtils.toggleInlineStyle(this.props.editorState, inlineStyle)); } /** * Handle pasting when cursor is in an image block. Paste the text as the * caption. Otherwise, let Draft do its thing. */ }, { key: 'render', /* Renders the `Editor`, `Toolbar` and the side `AddButton`. */ value: function render() { var _this2 = this; var _props = this.props, editorState = _props.editorState, editorEnabled = _props.editorEnabled, disableToolbar = _props.disableToolbar, showLinkEditToolbar = _props.showLinkEditToolbar, toolbarConfig = _props.toolbarConfig; var showAddButton = editorEnabled; var editorClass = 'md-RichEditor-editor' + (!editorEnabled ? ' md-RichEditor-readonly' : ''); var isCursorLink = false; if (editorEnabled && showLinkEditToolbar) { isCursorLink = (0, _model.isCursorBetweenLink)(editorState); } var blockButtons = this.configureToolbarBlockOptions(toolbarConfig); var inlineButtons = this.configureToolbarInlineOptions(toolbarConfig); return _react2.default.createElement( 'div', { className: 'md-RichEditor-root' }, _react2.default.createElement( 'div', { className: editorClass }, _react2.default.createElement(_draftJs.Editor, _extends({ ref: function ref(node) { _this2._editorNode = node; } }, this.props, { editorState: editorState, blockRendererFn: this.blockRendererFn, blockStyleFn: this.props.blockStyleFn, onChange: this.onChange, onTab: this.onTab, onUpArrow: this.onUpArrow, onDownArrow: this.onDownArrow, blockRenderMap: this.props.blockRenderMap, handleKeyCommand: this.handleKeyCommand, handleBeforeInput: this.handleBeforeInput, handleReturn: this.handleReturn, handlePastedText: this.handlePastedText, customStyleMap: this.props.customStyleMap, readOnly: !editorEnabled, keyBindingFn: this.props.keyBindingFn, placeholder: this.props.placeholder, spellCheck: editorEnabled && this.props.spellCheck })), this.props.sideButtons.length > 0 && showAddButton && _react2.default.createElement(_addbutton2.default, { editorState: editorState, getEditorState: this.getEditorState, setEditorState: this.onChange, focus: this.focus, sideButtons: this.props.sideButtons }), !disableToolbar && _react2.default.createElement(_toolbar2.default, { ref: function ref(c) { _this2.toolbar = c; }, editorNode: this._editorNode, editorState: editorState, toggleBlockType: this.toggleBlockType, toggleInlineStyle: this.toggleInlineStyle, editorEnabled: editorEnabled, setLink: this.setLink, focus: this.focus, blockButtons: blockButtons, inlineButtons: inlineButtons }), isCursorLink && _react2.default.createElement(_LinkEditComponent2.default, _extends({}, isCursorLink, { editorState: editorState, removeLink: this.removeLink, editLink: this.editLinkAfterSelection })) ) ); } }]); return MediumDraftEditor; }(_react2.default.Component); MediumDraftEditor.propTypes = { beforeInput: _propTypes2.default.func, keyBindingFn: _propTypes2.default.func, customStyleMap: _propTypes2.default.object, blockStyleFn: _propTypes2.default.func, rendererFn: _propTypes2.default.func, editorEnabled: _propTypes2.default.bool, spellCheck: _propTypes2.default.bool, stringToTypeMap: _propTypes2.default.object, blockRenderMap: _propTypes2.default.object, blockButtons: _propTypes2.default.arrayOf(_propTypes2.default.shape({ label: _propTypes2.default.oneOfType([_propTypes2.default.string, _propTypes2.default.element, _propTypes2.default.object]), style: _propTypes2.default.string.isRequired, icon: _propTypes2.default.string, description: _propTypes2.default.string, onClick: _propTypes2.default.func })), inlineButtons: _propTypes2.default.arrayOf(_propTypes2.default.shape({ label: _propTypes2.default.oneOfType([_propTypes2.default.string, _propTypes2.default.element, _propTypes2.default.object]), style: _propTypes2.default.string.isRequired, icon: _propTypes2.default.string, description: _propTypes2.default.string, onClick: _propTypes2.default.func })), placeholder: _propTypes2.default.string, imageCaptionPlaceholder: _propTypes2.default.string, continuousBlocks: _propTypes2.default.arrayOf(_propTypes2.default.string), sideButtons: _propTypes2.default.arrayOf(_propTypes2.default.shape({ title: _propTypes2.default.string.isRequired, component: _propTypes2.default.func })), editorState: _propTypes2.default.object.isRequired, onChange: _propTypes2.default.func.isRequired, onTab: _propTypes2.default.func, onUpArrow: _propTypes2.default.func, onDownArrow: _propTypes2.default.func, handleKeyCommand: _propTypes2.default.func, handleReturn: _propTypes2.default.func, handlePastedText: _propTypes2.default.func, disableToolbar: _propTypes2.default.bool, showLinkEditToolbar: _propTypes2.default.bool, toolbarConfig: _propTypes2.default.object, processURL: _propTypes2.default.func }; MediumDraftEditor.defaultProps = { beforeInput: _beforeinput2.default, keyBindingFn: _keybinding2.default, customStyleMap: _customstylemap2.default, blockStyleFn: _blockStyleFn2.default, rendererFn: _customrenderer2.default, editorEnabled: true, spellCheck: true, stringToTypeMap: _beforeinput.StringToTypeMap, blockRenderMap: _rendermap2.default, blockButtons: _toolbar.BLOCK_BUTTONS, inlineButtons: _toolbar.INLINE_BUTTONS, placeholder: 'Write your story...', imageCaptionPlaceholder: 'Add image caption...', continuousBlocks: [_constants.Block.UNSTYLED, _constants.Block.BLOCKQUOTE, _constants.Block.OL, _constants.Block.UL, _constants.Block.CODE, _constants.Block.TODO], sideButtons: [{ title: 'Image', component: _image2.default }], disableToolbar: false, showLinkEditToolbar: true, toolbarConfig: {} }; exports.default = MediumDraftEditor;