@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
JavaScript
"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;