UNPKG

@accordproject/markdown-editor

Version:

A rich text editor that can read and write markdown text. Based on Slate.js.

704 lines (562 loc) 21.9 kB
"use strict"; var _interopRequireWildcard = require("@babel/runtime/helpers/interopRequireWildcard"); var _interopRequireDefault = require("@babel/runtime/helpers/interopRequireDefault"); Object.defineProperty(exports, "__esModule", { value: true }); exports.default = void 0; var _regenerator = _interopRequireDefault(require("@babel/runtime/regenerator")); var _asyncToGenerator2 = _interopRequireDefault(require("@babel/runtime/helpers/asyncToGenerator")); var _extends2 = _interopRequireDefault(require("@babel/runtime/helpers/extends")); var _slicedToArray2 = _interopRequireDefault(require("@babel/runtime/helpers/slicedToArray")); var _taggedTemplateLiteral2 = _interopRequireDefault(require("@babel/runtime/helpers/taggedTemplateLiteral")); var _react = _interopRequireWildcard(require("react")); var _markdownHtml = require("@accordproject/markdown-html"); var _markdownSlate = require("@accordproject/markdown-slate"); var _slateReact = require("slate-react"); var _slate = require("slate"); var _propTypes = _interopRequireDefault(require("prop-types")); var _styledComponents = _interopRequireDefault(require("styled-components")); var _isHotkey = _interopRequireDefault(require("is-hotkey")); var _schema = _interopRequireDefault(require("../schema")); var _PluginManager = _interopRequireDefault(require("../PluginManager")); var _FormattingToolbar = _interopRequireDefault(require("../FormattingToolbar")); var _list = _interopRequireDefault(require("../plugins/list")); var _blockquote = _interopRequireDefault(require("../plugins/blockquote")); var CONST = _interopRequireWildcard(require("../constants")); var action = _interopRequireWildcard(require("../FormattingToolbar/toolbarMethods")); require("../styles.css"); function _templateObject3() { const data = (0, _taggedTemplateLiteral2.default)(["\n font-family: serif;\n"]); _templateObject3 = function _templateObject3() { return data; }; return data; } function _templateObject2() { const data = (0, _taggedTemplateLiteral2.default)(["\n position: sticky;\n z-index: 1;\n top: 0;\n height: 36px;\n background: ", ";\n box-shadow: ", ";\n"]); _templateObject2 = function _templateObject2() { return data; }; return data; } function _templateObject() { const data = (0, _taggedTemplateLiteral2.default)(["\n background: #fff;\n min-height: ", ";\n max-width: ", ";\n min-width: ", ";\n border-radius: ", ";\n border: ", ";\n box-shadow: ", ";\n margin: ", ";\n font-family: serif;\n font-style: normal;\n font-weight: normal;\n font-size: 0.88em;\n line-height: 100%;\n word-spacing: normal;\n letter-spacing: normal;\n text-decoration: none;\n text-transform: none;\n text-align: left;\n text-indent: 0ex;\n display: flex;\n\n > div {\n width: 100%;\n }\n\n .doc-inner {\n width: 100%;\n height: 100%;\n padding: 20px;\n }\n"]); _templateObject = function _templateObject() { return data; }; return data; } const EditorWrapper = _styledComponents.default.div(_templateObject(), props => props.EDITOR_HEIGHT || '750px', props => props.EDITOR_WIDTH || 'none', props => props.EDITOR_WIDTH || 'none', props => props.EDITOR_BORDER_RADIUS || ' 10px', props => props.EDITOR_BORDER || ' 1px solid #979797', props => props.EDITOR_SHADOW || ' 1px 2px 4px rgba(0, 0, 0, .5)', props => props.EDITOR_MARGIN || '5px auto'); const ToolbarWrapper = _styledComponents.default.div(_templateObject2(), props => props.TOOLBAR_BACKGROUND || '#FFF', props => props.TOOLBAR_SHADOW || 'none'); const Heading = _styledComponents.default.div(_templateObject3()); Heading.propTypes = { type: _propTypes.default.oneOf(['h1', 'h2', 'h3', 'h4', 'h5', 'h6']) }; /** * A plugin based rich-text editor that uses Common Mark for serialization. * The default slate value to be edited is passed in props 'value' * while the plugins are passed in the 'plugins' property. * * The rich text editor is editable is passed to the props.onChange * callback. * * When props.lockText is true the editor will lock all text against edits * except for variables. * * @param {*} props the props for the component. See the declared PropTypes * for details. */ // eslint-disable-next-line react/display-name const SlateAsInputEditor = _react.default.forwardRef((props, ref) => { /** * Destructure props for efficiency */ const onChange = props.onChange, value = props.value; const editorProps = props.editorProps || Object.create(null); const plugins = _react.default.useMemo(() => props.plugins ? props.plugins.concat([(0, _list.default)(), (0, _blockquote.default)()]) : [(0, _list.default)(), (0, _blockquote.default)()], [props.plugins]); /** * A reference to the Slate Editor. */ const editorRef = ref || (0, _react.useRef)(null); /** * Slate Schema augmented by plugins */ const _useState = (0, _react.useState)(null), _useState2 = (0, _slicedToArray2.default)(_useState, 2), slateSchema = _useState2[0], setSlateSchema = _useState2[1]; /** * Updates the Slate Schema when the plugins change */ (0, _react.useEffect)(() => { let augmentedSchema = _schema.default; // sort the plugins by name to get determinism plugins.sort((pluginA, pluginB) => pluginA.name.localeCompare(pluginB.name)); // allow each plugin to contribute to the schema plugins.forEach(plugin => { if (plugin.augmentSchema) { augmentedSchema = plugin.augmentSchema(augmentedSchema); } }); setSlateSchema(augmentedSchema); }, [plugins]); /** * Render a Slate inline. */ // @ts-ignore const renderInline = (0, _react.useCallback)((props, editor, next) => { const attributes = props.attributes, children = props.children, node = props.node; switch (node.type) { case 'link': return _react.default.createElement("a", (0, _extends2.default)({}, attributes, { href: node.data.get('href') }), children); case 'image': return _react.default.createElement("img", (0, _extends2.default)({}, attributes, { alt: node.data.get('title'), src: node.data.get('href') })); case 'html_inline': return _react.default.createElement("span", (0, _extends2.default)({ className: "html_inline" }, attributes), node.data.get('content')); case 'softbreak': return _react.default.createElement("span", (0, _extends2.default)({ className: "softbreak" }, attributes), " ", children); case 'linebreak': return _react.default.createElement("br", (0, _extends2.default)({ className: "linebreak" }, attributes)); default: return next(); } }, []); /** * Renders a block */ // @ts-ignore const renderBlock = (0, _react.useCallback)((props, editor, next) => { const node = props.node, attributes = props.attributes, children = props.children; switch (node.type) { case CONST.PARAGRAPH: return _react.default.createElement("p", attributes, children); case CONST.H1: return _react.default.createElement(Heading, (0, _extends2.default)({ as: "h1" }, attributes), children); case CONST.H2: return _react.default.createElement(Heading, (0, _extends2.default)({ as: "h2" }, attributes), children); case CONST.H3: return _react.default.createElement(Heading, (0, _extends2.default)({ as: "h3" }, attributes), children); case 'heading_four': return _react.default.createElement(Heading, (0, _extends2.default)({ as: "h4" }, attributes), children); case 'heading_five': return _react.default.createElement(Heading, (0, _extends2.default)({ as: "h5" }, attributes), children); case 'heading_six': return _react.default.createElement(Heading, (0, _extends2.default)({ as: "h6" }, attributes), children); case 'horizontal_rule': return _react.default.createElement("div", (0, _extends2.default)({ className: "hr" }, attributes), children); case 'code_block': return _react.default.createElement("pre", attributes, children); case 'html_block': return _react.default.createElement("pre", (0, _extends2.default)({ className: "html_block" }, attributes), children); default: return next(); } }, []); /** * Render a Slate mark. */ // @ts-ignore const renderMark = (0, _react.useCallback)((props, editor, next) => { const children = props.children, mark = props.mark, attributes = props.attributes; switch (mark.type) { case CONST.FONT_BOLD: return _react.default.createElement("strong", attributes, children); case CONST.FONT_ITALIC: return _react.default.createElement("em", attributes, children); // case 'underline': // return <u {...{ attributes }}>{children}</u>; case 'html': case CONST.FONT_CODE: return _react.default.createElement("code", attributes, children); case 'error': return _react.default.createElement("span", (0, _extends2.default)({ className: "error" }, attributes), children); default: return next(); } }, []); /** * Returns true if the editor should allow an edit. Edits are allowed for all * text unless the lockText parameter is set in the state of the editor, in which * case the decision is delegated to the PluginManager. * @param {Editor} editor the Slate Editor * @param {string} code the type of edit requested */ const isEditable = (0, _react.useCallback)((editor, code) => { if (editor.props.readOnly) { return false; } if (editor.props.lockText) { const pluginManager = new _PluginManager.default(plugins); return pluginManager.isEditable(editor, code); } return true; }, [plugins]); /** * On backspace, if at the start of a non-paragraph, convert it back into a * paragraph node. * * @param {Event} event * @param {Editor} editor * @param {Function} next */ const handleBackspace = (event, editor, next) => { const value = editor.value; const selection = value.selection; if (editor.props.lockText && !isEditable(editor, 'backspace')) { event.preventDefault(); // prevent editing non-editable text return undefined; } if (selection.isExpanded) return next(); if (selection.start.offset !== 0) return next(); const startBlock = value.startBlock; if (startBlock.type === CONST.PARAGRAPH) return next(); event.preventDefault(); editor.setBlocks(CONST.PARAGRAPH); return undefined; }; /** * Check if the current selection has a mark with `code` in it. * * @param {Object} value * @return {Boolean} */ const isCodespan = value => value.activeMarks.some(mark => mark.type === CONST.FONT_CODE); /** * On return, if at the end of a node type that should not be extended, * create a new paragraph below it. * * @param {Event} event * @param {Editor} editor * @param {Function} next */ const handleEnter = (event, editor, next) => { const value = editor.value; const selection = value.selection; const end = selection.end, isExpanded = selection.isExpanded; if (!isEditable(editor, 'enter')) { event.preventDefault(); // prevent adding newlines in variables return false; } if (action.isOnlyLink(editor)) { const isLinkBool = action.hasLinks(editor); action.applyLinkUpdate(event, editor, isLinkBool); return true; } if (isExpanded) return next(); const startBlock = value.startBlock; if (end.offset !== startBlock.text.length) return next(); // Hitting enter while in a codespan will break out of the span if (isCodespan(value)) { event.preventDefault(); editor.removeMark(CONST.FONT_CODE); editor.insertBlock(CONST.PARAGRAPH); return false; } // when you hit enter after a heading we insert a paragraph if (startBlock.type.startsWith('heading')) { event.preventDefault(); return editor.insertBlock(CONST.PARAGRAPH); } // if you hit enter inside anything that is not a heading // we use the default behavior return next(); }; /** * Method to handle lists * @param {*} editor * @param {*} type */ const handleList = (editor, type) => { if (action.isSelectionList(editor.value)) { if (action.currentList(editor.value).type === type) { return action.transformListToParagraph(editor, type); } return action.transformListSwap(editor, type, editor.value); } if (action.isSelectionInput(editor.value, CONST.BLOCK_QUOTE)) { editor.unwrapBlock(CONST.BLOCK_QUOTE); return action.transformParagraphToList(editor, type); } return action.transformParagraphToList(editor, type); }; /** * Method to handle block quotes * @param {*} editor */ const handleBlockQuotes = editor => { if (action.isSelectionInput(editor.value, CONST.BLOCK_QUOTE)) { editor.unwrapBlock(CONST.BLOCK_QUOTE); } else if (action.isSelectionList(editor.value)) { if (action.isSelectionInput(editor.value, CONST.OL_LIST)) { action.transformListToParagraph(editor, CONST.OL_LIST); } else { action.transformListToParagraph(editor, CONST.UL_LIST); } editor.wrapBlock(CONST.BLOCK_QUOTE); } else { editor.wrapBlock(CONST.BLOCK_QUOTE); } }; /** * Called upon a keypress * @param {*} event * @param {*} editor * @param {*} next */ const onKeyDown = /*#__PURE__*/function () { var _ref = (0, _asyncToGenerator2.default)( /*#__PURE__*/_regenerator.default.mark(function _callee(event, editor, next) { var onUndoOrRedo, isEnter, isBackSpace, isSpecialKey, inputHandler; return _regenerator.default.wrap(function _callee$(_context) { while (1) switch (_context.prev = _context.next) { case 0: onUndoOrRedo = editor.props.editorProps.onUndoOrRedo; isEnter = () => handleEnter(event, editor, next); isBackSpace = () => handleBackspace(event, editor, next); isSpecialKey = () => { switch (true) { case (0, _isHotkey.default)('mod+z', event): editor.undo(); if (onUndoOrRedo) return onUndoOrRedo(editor); return next(); case (0, _isHotkey.default)('mod+shift+z', event): editor.redo(); if (onUndoOrRedo) return onUndoOrRedo(editor); return next(); case (0, _isHotkey.default)('mod+b', event) && isEditable(editor, CONST.FONT_BOLD): return editor.toggleMark(CONST.FONT_BOLD); case (0, _isHotkey.default)('mod+i', event) && isEditable(editor, CONST.FONT_ITALIC): return editor.toggleMark(CONST.FONT_ITALIC); case (0, _isHotkey.default)('mod+alt+c', event) && isEditable(editor, CONST.FONT_CODE): return editor.toggleMark(CONST.FONT_CODE); case (0, _isHotkey.default)('mod+shift+.', event) && isEditable(editor, CONST.BLOCK_QUOTE): return handleBlockQuotes(editor); case (0, _isHotkey.default)('mod+shift+7', event) && isEditable(editor, CONST.OL_LIST): return handleList(editor, CONST.OL_LIST); case (0, _isHotkey.default)('mod+shift+8', event) && isEditable(editor, CONST.UL_LIST): return handleList(editor, CONST.UL_LIST); default: return next(); } }; inputHandler = key => { const cases = { Enter: isEnter, Backspace: isBackSpace, default: isSpecialKey }; return (cases[key] || cases.default)(); }; inputHandler(event.key); case 6: case "end": return _context.stop(); } }, _callee); })); return function onKeyDown(_x, _x2, _x3) { return _ref.apply(this, arguments); }; }(); /** * Called on a paste * @param {*} event * @param {*} editor * @param {*} next * @return {*} the react component */ const onPaste = (event, editor, next) => { if (!isEditable(editor, 'paste')) { return false; } if (isEditable(editor, 'paste')) { event.preventDefault(); const transfer = (0, _slateReact.getEventTransfer)(event); if (transfer.type === 'html') { const htmlTransformer = new _markdownHtml.HtmlTransformer(); const slateTransformer = new _markdownSlate.SlateTransformer(); // @ts-ignore const ciceroMark = htmlTransformer.toCiceroMark(transfer.html, 'json'); const _Value$fromJSON = _slate.Value.fromJSON(slateTransformer.fromCiceroMark(ciceroMark)), document = _Value$fromJSON.document; editor.insertFragment(document); return; } } return next(); }; /** * When in lockText mode prevent edits to non-variables * @param {*} event * @param {*} editor * @param {*} next */ const onBeforeInput = (event, editor, next) => { if (isEditable(editor, 'input')) { return next(); } event.preventDefault(); return false; }; /** * Render the toolbar. */ const renderEditor = (0, _react.useCallback)((props, editor, next) => { const children = next(); const pluginManager = new _PluginManager.default(plugins); return _react.default.createElement("div", null, _react.default.createElement(_FormattingToolbar.default, { editor: editor, pluginManager: pluginManager, editorProps: editorProps, lockText: props.lockText }), children); }, [editorProps, plugins]); const onChangeHandler = ({ value }) => { if (props.readOnly) return; onChange(value); }; const onFocusHandler = (_event, editor, _next) => { // see https://github.com/accordproject/markdown-editor/issues/162 setTimeout(editor.focus, 0); }; const onCutHandler = (event, editor, next) => { if (!isEditable(editor, 'cut')) { event.preventDefault(); return false; } return next(); }; return _react.default.createElement("div", { className: "ap-markdown-editor" }, _react.default.createElement(ToolbarWrapper, (0, _extends2.default)({}, editorProps, { id: "slate-toolbar-wrapper-id" })), _react.default.createElement(EditorWrapper, editorProps, _react.default.createElement(_slateReact.Editor, (0, _extends2.default)({}, props, { ref: editorRef, className: "doc-inner", value: _slate.Value.fromJSON(value), readOnly: props.readOnly, onChange: onChangeHandler, onCut: onCutHandler, onFocus: onFocusHandler, schema: slateSchema, plugins: plugins, onBeforeInput: onBeforeInput, onKeyDown: onKeyDown, onPaste: onPaste, renderBlock: renderBlock, renderInline: renderInline, renderMark: renderMark, editorProps: editorProps, renderEditor: renderEditor })))); }); /** * The property types for this component */ SlateAsInputEditor.propTypes = { /** * Initial contents for the editor (slate value) */ value: _propTypes.default.object, /** * Optional styling props for this editor and toolbar */ editorProps: _propTypes.default.shape({ BUTTON_BACKGROUND_INACTIVE: _propTypes.default.string, BUTTON_BACKGROUND_ACTIVE: _propTypes.default.string, BUTTON_SYMBOL_INACTIVE: _propTypes.default.string, BUTTON_SYMBOL_ACTIVE: _propTypes.default.string, DROPDOWN_COLOR: _propTypes.default.string, EDITOR_BORDER: _propTypes.default.string, EDITOR_BORDER_RADIUS: _propTypes.default.string, EDITOR_HEIGHT: _propTypes.default.string, EDITOR_MARGIN: _propTypes.default.string, EDITOR_SHADOW: _propTypes.default.string, EDITOR_WIDTH: _propTypes.default.string, TOOLBAR_BACKGROUND: _propTypes.default.string, TOOLTIP_BACKGROUND: _propTypes.default.string, TOOLTIP: _propTypes.default.string, TOOLBAR_SHADOW: _propTypes.default.string }), /** * A callback that receives the Slate Value object and * the corresponding markdown text */ onChange: _propTypes.default.func.isRequired, /** * If true then only variables are editable in the Slate editor. */ lockText: _propTypes.default.bool.isRequired, /** * When set to the true the contents of the editor are read-only */ readOnly: _propTypes.default.bool, /** * An array of plugins to extend the functionality of the editor */ plugins: _propTypes.default.arrayOf(_propTypes.default.shape({ onEnter: _propTypes.default.func, onKeyDown: _propTypes.default.func, onBeforeInput: _propTypes.default.func, renderBlock: _propTypes.default.func, renderInline: _propTypes.default.func, name: _propTypes.default.string.isRequired })) }; /** * The default property values for this component */ SlateAsInputEditor.defaultProps = { value: _slate.Value.fromJSON({ object: 'value', document: { object: 'document', data: {}, nodes: [{ object: 'block', type: CONST.PARAGRAPH, data: {}, nodes: [{ object: 'text', text: 'Welcome! Edit this text to get started.', marks: [] }] }] } }) }; var _default = SlateAsInputEditor; exports.default = _default;