draftail
Version:
ππΈ A configurable rich text editor built with Draft.js
1,195 lines (1,178 loc) β’ 102 kB
JavaScript
'use strict';
Object.defineProperty(exports, '__esModule', { value: true });
var jsxRuntime = require('react/jsx-runtime');
var React = require('react');
var draftJs = require('draft-js');
var draftjsFilters = require('draftjs-filters');
var Editor = require('draft-js-plugins-editor');
var draftjsConductor = require('draftjs-conductor');
var decorateComponentWithProps = require('decorate-component-with-props');
var isSoftNewlineEvent = require('draft-js/lib/isSoftNewlineEvent');
var downshift = require('downshift');
var Tippy = require('@tippyjs/react');
function _interopDefaultLegacy (e) { return e && typeof e === 'object' && 'default' in e ? e : { 'default': e }; }
var React__default = /*#__PURE__*/_interopDefaultLegacy(React);
var Editor__default = /*#__PURE__*/_interopDefaultLegacy(Editor);
var decorateComponentWithProps__default = /*#__PURE__*/_interopDefaultLegacy(decorateComponentWithProps);
var isSoftNewlineEvent__default = /*#__PURE__*/_interopDefaultLegacy(isSoftNewlineEvent);
var Tippy__default = /*#__PURE__*/_interopDefaultLegacy(Tippy);
// See https://github.com/facebook/draft-js/blob/master/src/model/immutable/DefaultDraftBlockRenderMap.js
const BLOCK_TYPE = {
// This is used to represent a normal text block (paragraph).
UNSTYLED: "unstyled",
HEADER_ONE: "header-one",
HEADER_TWO: "header-two",
HEADER_THREE: "header-three",
HEADER_FOUR: "header-four",
HEADER_FIVE: "header-five",
HEADER_SIX: "header-six",
UNORDERED_LIST_ITEM: "unordered-list-item",
ORDERED_LIST_ITEM: "ordered-list-item",
BLOCKQUOTE: "blockquote",
CODE: "code-block",
// This represents a "custom" block, not for rich text, with arbitrary content.
ATOMIC: "atomic",
};
const ENTITY_TYPE = {
LINK: "LINK",
IMAGE: "IMAGE",
HORIZONTAL_RULE: "HORIZONTAL_RULE",
};
// See https://github.com/facebook/draft-js/blob/master/src/model/immutable/DefaultDraftInlineStyle.js
const INLINE_STYLE = {
BOLD: "BOLD",
ITALIC: "ITALIC",
CODE: "CODE",
UNDERLINE: "UNDERLINE",
STRIKETHROUGH: "STRIKETHROUGH",
MARK: "MARK",
QUOTATION: "QUOTATION",
SMALL: "SMALL",
SAMPLE: "SAMPLE",
INSERT: "INSERT",
DELETE: "DELETE",
KEYBOARD: "KEYBOARD",
SUPERSCRIPT: "SUPERSCRIPT",
SUBSCRIPT: "SUBSCRIPT",
};
const BLOCK_TYPES = Object.values(BLOCK_TYPE);
const ENTITY_TYPES = Object.values(ENTITY_TYPE);
const INLINE_STYLES = Object.values(INLINE_STYLE);
const KEY_COMMANDS = [
...BLOCK_TYPES,
...ENTITY_TYPES,
...INLINE_STYLES,
// Lowercase identifiers used by Draft.js
// See https://github.com/facebook/draft-js/blob/585af35c3a8c31fefb64bc884d4001faa96544d3/src/model/constants/DraftEditorCommand.js#L58-L61.
"bold",
"italic",
"underline",
"code",
];
const FONT_FAMILY_MONOSPACE = "Consolas, Menlo, Monaco, Lucida Console, Liberation Mono, DejaVu Sans Mono, Bitstream Vera Sans Mono, Courier New, monospace, sans-serif";
// See https://github.com/facebook/draft-js/blob/master/src/model/immutable/DefaultDraftInlineStyle.js
const CUSTOM_STYLE_MAP = {
[INLINE_STYLE.BOLD]: {
fontWeight: "bold",
},
[INLINE_STYLE.ITALIC]: {
fontStyle: "italic",
},
[INLINE_STYLE.STRIKETHROUGH]: {
textDecoration: "line-through",
},
[INLINE_STYLE.UNDERLINE]: {
textDecoration: "underline",
},
[INLINE_STYLE.CODE]: {
padding: "0.2em 0.3125em",
margin: "0",
fontSize: "85%",
backgroundColor: "rgba(27, 31, 35, 0.05)",
fontFamily: FONT_FAMILY_MONOSPACE,
borderRadius: "3px",
},
[INLINE_STYLE.MARK]: {
backgroundColor: "Mark",
color: "MarkText",
},
[INLINE_STYLE.QUOTATION]: {
fontStyle: "italic",
},
[INLINE_STYLE.SMALL]: {
fontSize: "smaller",
},
[INLINE_STYLE.SAMPLE]: {
fontFamily: FONT_FAMILY_MONOSPACE,
},
[INLINE_STYLE.INSERT]: {
textDecoration: "underline",
},
[INLINE_STYLE.DELETE]: {
textDecoration: "line-through",
},
[INLINE_STYLE.KEYBOARD]: {
fontFamily: FONT_FAMILY_MONOSPACE,
padding: "3px 5px",
fontSize: "11px",
lineHeight: "10px",
color: "#444d56",
verticalAlign: "middle",
backgroundColor: "#fafbfc",
border: "solid 1px #c6cbd1",
borderBottomColor: "#959da5",
borderRadius: "3px",
boxShadow: "inset 0 -1px 0 #959da5",
},
[INLINE_STYLE.SUPERSCRIPT]: {
fontSize: "80%",
verticalAlign: "super",
lineHeight: "1",
},
[INLINE_STYLE.SUBSCRIPT]: {
fontSize: "80%",
verticalAlign: "sub",
lineHeight: "1",
},
};
const BR_TYPE = "BR";
const UNDO_TYPE = "undo";
const REDO_TYPE = "redo";
// Originally from https://github.com/facebook/draft-js/blob/master/src/component/utils/getDefaultKeyBinding.js.
const KEY_CODES = {
K: 75,
B: 66,
U: 85,
J: 74,
I: 73,
X: 88,
"0": 48,
"1": 49,
"2": 50,
"3": 51,
"4": 52,
"5": 53,
"6": 54,
"7": 55,
"8": 56,
".": 190,
",": 188,
};
const INPUT_BLOCK_MAP = {
"* ": BLOCK_TYPE.UNORDERED_LIST_ITEM,
"- ": BLOCK_TYPE.UNORDERED_LIST_ITEM,
"1. ": BLOCK_TYPE.ORDERED_LIST_ITEM,
"# ": BLOCK_TYPE.HEADER_ONE,
"## ": BLOCK_TYPE.HEADER_TWO,
"### ": BLOCK_TYPE.HEADER_THREE,
"#### ": BLOCK_TYPE.HEADER_FOUR,
"##### ": BLOCK_TYPE.HEADER_FIVE,
"###### ": BLOCK_TYPE.HEADER_SIX,
"> ": BLOCK_TYPE.BLOCKQUOTE,
// It makes more sense not to require a space here.
// This matches how Dropbox Paper operates.
"```": BLOCK_TYPE.CODE,
};
const INPUT_STYLE_MAP = [
// Order matters, as shorter patterns are contained in the longer ones.
{ pattern: "**", type: INLINE_STYLE.BOLD },
{ pattern: "__", type: INLINE_STYLE.BOLD },
{ pattern: "*", type: INLINE_STYLE.ITALIC },
{ pattern: "_", type: INLINE_STYLE.ITALIC },
{ pattern: "~~", type: INLINE_STYLE.STRIKETHROUGH },
{ pattern: "~", type: INLINE_STYLE.STRIKETHROUGH },
{ pattern: "`", type: INLINE_STYLE.CODE },
].map(({ pattern, type }) => {
const pat = pattern.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const char = pattern[0].replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
// https://regexper.com/#%28%5Cs%7C%5E%29__%28%5B%5E%5Cs_%5D%7B1%2C2%7D%7C%5B%5E%5Cs_%5D.%2B%5B%5E%5Cs_%5D%29__%24
// This is stored as an escaped string instead of a RegExp object because they are stateful.
// This regex encapsulates a few rules:
// - The pattern must be preceded by whitespace, or be at the start of the input.
// - The pattern must end the input.
// - In-between the start and end patterns, there can't be only whitespace or characters from the pattern.
// - There has to be at least 1 char that's not whitespace or the patternβs char.
const regex = `(\\s|^)${pat}([^\\s${char}]{1,2}|[^\\s${char}].+[^\\s${char}])${pat}$`;
return {
pattern,
type,
regex,
};
});
const INPUT_ENTITY_MAP = {
[ENTITY_TYPE.HORIZONTAL_RULE]: "---",
};
const LABELS = {
[BLOCK_TYPE.UNSTYLED]: "P",
[BLOCK_TYPE.HEADER_ONE]: "H1",
[BLOCK_TYPE.HEADER_TWO]: "H2",
[BLOCK_TYPE.HEADER_THREE]: "H3",
[BLOCK_TYPE.HEADER_FOUR]: "H4",
[BLOCK_TYPE.HEADER_FIVE]: "H5",
[BLOCK_TYPE.HEADER_SIX]: "H6",
[BLOCK_TYPE.UNORDERED_LIST_ITEM]: "UL",
[BLOCK_TYPE.ORDERED_LIST_ITEM]: "OL",
[BLOCK_TYPE.CODE]: "{ }",
[BLOCK_TYPE.BLOCKQUOTE]: "β",
[INLINE_STYLE.BOLD]: "π",
[INLINE_STYLE.ITALIC]: "π",
[INLINE_STYLE.CODE]: "{ }",
[INLINE_STYLE.UNDERLINE]: "U",
[INLINE_STYLE.STRIKETHROUGH]: "S",
[INLINE_STYLE.MARK]: "β",
[INLINE_STYLE.QUOTATION]: "β",
[INLINE_STYLE.SMALL]: "Small",
[INLINE_STYLE.SAMPLE]: "Data",
[INLINE_STYLE.INSERT]: "Ins",
[INLINE_STYLE.DELETE]: "Del",
[INLINE_STYLE.SUPERSCRIPT]: "Sup",
[INLINE_STYLE.SUBSCRIPT]: "Sub",
[INLINE_STYLE.KEYBOARD]: "β",
[ENTITY_TYPE.LINK]: "π",
[ENTITY_TYPE.IMAGE]: "πΌ",
[ENTITY_TYPE.HORIZONTAL_RULE]: "β",
[BR_TYPE]: "β΅",
[UNDO_TYPE]: "βΊ",
[REDO_TYPE]: "β»",
};
const DESCRIPTIONS = {
[BLOCK_TYPE.UNSTYLED]: "Paragraph",
[BLOCK_TYPE.HEADER_ONE]: "Heading 1",
[BLOCK_TYPE.HEADER_TWO]: "Heading 2",
[BLOCK_TYPE.HEADER_THREE]: "Heading 3",
[BLOCK_TYPE.HEADER_FOUR]: "Heading 4",
[BLOCK_TYPE.HEADER_FIVE]: "Heading 5",
[BLOCK_TYPE.HEADER_SIX]: "Heading 6",
[BLOCK_TYPE.UNORDERED_LIST_ITEM]: "Bulleted list",
[BLOCK_TYPE.ORDERED_LIST_ITEM]: "Numbered list",
[BLOCK_TYPE.BLOCKQUOTE]: "Blockquote",
[BLOCK_TYPE.CODE]: "Code block",
[INLINE_STYLE.BOLD]: "Bold",
[INLINE_STYLE.ITALIC]: "Italic",
[INLINE_STYLE.CODE]: "Code",
[INLINE_STYLE.UNDERLINE]: "Underline",
[INLINE_STYLE.STRIKETHROUGH]: "Strikethrough",
[INLINE_STYLE.MARK]: "Highlight",
[INLINE_STYLE.QUOTATION]: "Inline quotation",
[INLINE_STYLE.SMALL]: "Small",
[INLINE_STYLE.SAMPLE]: "Program output",
[INLINE_STYLE.INSERT]: "Inserted",
[INLINE_STYLE.DELETE]: "Deleted",
[INLINE_STYLE.KEYBOARD]: "Shortcut key",
[INLINE_STYLE.SUPERSCRIPT]: "Superscript",
[INLINE_STYLE.SUBSCRIPT]: "Subscript",
[ENTITY_TYPE.LINK]: "Link",
[ENTITY_TYPE.IMAGE]: "Image",
[ENTITY_TYPE.HORIZONTAL_RULE]: "Horizontal line",
[BR_TYPE]: "Line break",
[UNDO_TYPE]: "Undo",
[REDO_TYPE]: "Redo",
};
const KEYBOARD_SHORTCUTS = {
[BLOCK_TYPE.UNSTYLED]: "β«",
[BLOCK_TYPE.HEADER_ONE]: "#",
[BLOCK_TYPE.HEADER_TWO]: "##",
[BLOCK_TYPE.HEADER_THREE]: "###",
[BLOCK_TYPE.HEADER_FOUR]: "####",
[BLOCK_TYPE.HEADER_FIVE]: "#####",
[BLOCK_TYPE.HEADER_SIX]: "######",
[BLOCK_TYPE.UNORDERED_LIST_ITEM]: "-",
[BLOCK_TYPE.ORDERED_LIST_ITEM]: "1.",
[BLOCK_TYPE.BLOCKQUOTE]: ">",
[BLOCK_TYPE.CODE]: "```",
[INLINE_STYLE.BOLD]: { other: "Ctrl + B", macOS: "β + B" },
[INLINE_STYLE.ITALIC]: { other: "Ctrl + I", macOS: "β + I" },
[INLINE_STYLE.UNDERLINE]: {
other: "Ctrl + U",
macOS: "β + U",
},
[INLINE_STYLE.STRIKETHROUGH]: {
other: "Ctrl + β§ + X",
macOS: "β + β§ + X",
},
[INLINE_STYLE.SUPERSCRIPT]: {
other: "Ctrl + .",
macOS: "β + .",
},
[INLINE_STYLE.SUBSCRIPT]: {
other: "Ctrl + ,",
macOS: "β + ,",
},
[ENTITY_TYPE.LINK]: { other: "Ctrl + K", macOS: "β + K" },
[BR_TYPE]: "β§ + β΅",
[ENTITY_TYPE.HORIZONTAL_RULE]: "- - -",
[UNDO_TYPE]: { other: "Ctrl + Z", macOS: "β + Z" },
[REDO_TYPE]: { other: "Ctrl + β§ + Z", macOS: "β + β§ + Z" },
};
const HANDLED = "handled";
const NOT_HANDLED = "not-handled";
/**
* Inspired by draftjs-utils, with our custom functions.
*
* DraftUtils functions are utility helpers useful in isolation, specific to the Draft.js API,
* without ties to Draftail's specific behavior or other APIs.
*/
var DraftUtils = {
/**
* Returns the first selected block.
*/
getSelectedBlock(editorState) {
const selection = editorState.getSelection();
const content = editorState.getCurrentContent();
return content.getBlockMap().get(selection.getStartKey());
},
/**
* Returns the entity applicable to whole of current selection.
* An entity can not span multiple blocks.
* https://github.com/jpuri/draftjs-utils/blob/e81c0ae19c3b0fdef7e0c1b70d924398956be126/js/inline.js#L75
*/
getSelectionEntity(editorState) {
let entity;
const selection = editorState.getSelection();
let start = selection.getStartOffset();
let end = selection.getEndOffset();
if (start === end && start === 0) {
end = 1;
}
else if (start === end) {
start -= 1;
}
const block = this.getSelectedBlock(editorState);
for (let i = start; i < end; i += 1) {
const currentEntity = block.getEntityAt(i);
if (!currentEntity) {
entity = undefined;
break;
}
if (i === start) {
entity = currentEntity;
}
else if (entity !== currentEntity) {
entity = undefined;
break;
}
}
return entity;
},
/**
* Creates a selection on a given entity in the currently selected block.
* Returns the current selection if no entity key is provided, or if the entity could not be found.
*/
getEntitySelection(editorState, entityKey) {
const selection = editorState.getSelection();
if (!entityKey) {
return selection;
}
const block = this.getSelectedBlock(editorState);
let entityRange;
// https://github.com/jpuri/draftjs-utils/blob/e81c0ae19c3b0fdef7e0c1b70d924398956be126/js/inline.js#L111
block.findEntityRanges(
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-expect-error
(value) => value.get("entity") === entityKey, (start, end) => {
entityRange = {
start,
end,
};
});
if (!entityRange) {
return selection;
}
return selection.merge({
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-expect-error
anchorOffset: selection.isBackward ? entityRange.end : entityRange.start,
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-expect-error
focusOffset: selection.isBackward ? entityRange.start : entityRange.end,
});
},
/**
* Updates a given atomic block's entity, merging new data with the old one.
*/
updateBlockEntity(editorState, block, data) {
const content = editorState.getCurrentContent();
let nextContent = content.mergeEntityData(block.getEntityAt(0), data);
// To remove in Draft.js 0.11.
// This is necessary because entity data is still using a mutable, global store.
nextContent = draftJs.Modifier.mergeBlockData(nextContent, new draftJs.SelectionState({
anchorKey: block.getKey(),
anchorOffset: 0,
focusKey: block.getKey(),
focusOffset: block.getLength(),
}),
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-expect-error
{});
return draftJs.EditorState.push(editorState, nextContent, "apply-entity");
},
/**
* Inserts a horizontal rule in the place of the current selection.
* Returns updated EditorState.
* Inspired by DraftUtils.addLineBreakRemovingSelection.
*/
addHorizontalRuleRemovingSelection(editorState) {
const contentState = editorState.getCurrentContent();
const contentStateWithEntity = contentState.createEntity(ENTITY_TYPE.HORIZONTAL_RULE, "IMMUTABLE", {});
const entityKey = contentStateWithEntity.getLastCreatedEntityKey();
return draftJs.AtomicBlockUtils.insertAtomicBlock(editorState, entityKey, " ");
},
/**
* Changes a block type to be `newType`, setting its new text.
* Also removes the required characters from the characterList,
* and resets block data.
*/
resetBlockWithType(editorState, newType, newText = "", newData = {}) {
const contentState = editorState.getCurrentContent();
const selectionState = editorState.getSelection();
const key = selectionState.getStartKey();
const blockMap = contentState.getBlockMap();
const block = blockMap.get(key);
// Maintain persistence in the list while removing chars from the start.
// https://github.com/facebook/draft-js/blob/788595984da7c1e00d1071ea82b063ff87140be4/src/model/transaction/removeRangeFromContentState.js#L333
let chars = block.getCharacterList();
let startOffset = 0;
const sliceOffset = block.getText().length - newText.length;
while (startOffset < sliceOffset) {
chars = chars.shift();
startOffset += 1;
}
const newBlock = block.merge({
type: newType || block.getType(),
text: newText,
characterList: chars,
data: newData,
});
const newContentState = contentState.merge({
blockMap: blockMap.set(key, newBlock),
});
const newSelectionState = selectionState.merge({
anchorOffset: 0,
focusOffset: 0,
});
return draftJs.EditorState.acceptSelection(draftJs.EditorState.push(editorState, newContentState, "change-block-type"), newSelectionState);
},
/**
* Applies an inline style on a given range, based on a Markdown shortcut,
* removing the Markdown markers.
* Supports adding styles on existing styles, and entities.
*/
applyMarkdownStyle(editorState, range, char) {
const selection = editorState.getSelection();
let content = editorState.getCurrentContent();
const marked = selection.merge({
anchorOffset: range.start,
focusOffset: range.end,
});
const endMarker = selection.merge({
anchorOffset: range.end - range.pattern.length,
focusOffset: range.end,
});
const startMarker = selection.merge({
anchorOffset: range.start,
focusOffset: range.start + range.pattern.length,
});
// Remove the markers separately to preserve existing styles and entities on the marked text.
content = draftJs.Modifier.applyInlineStyle(content, marked, range.type);
content = draftJs.Modifier.removeRange(content, endMarker, "forward");
content = draftJs.Modifier.removeRange(content, startMarker, "forward");
const offset = selection.getFocusOffset() - range.pattern.length * 2;
const endSelection = selection.merge({
anchorOffset: offset,
focusOffset: offset,
});
content = content.merge({ selectionAfter: endSelection });
content = draftJs.Modifier.insertText(content, endSelection, char);
return draftJs.EditorState.push(editorState, content, "change-inline-style");
},
/**
* Removes the block at the given key.
*/
removeBlock(editorState, key) {
const content = editorState.getCurrentContent();
const blockMap = content.getBlockMap().remove(key);
return draftJs.EditorState.set(editorState, {
currentContent: content.merge({
blockMap,
}),
});
},
/**
* Removes a block-level entity, turning the block into an empty paragraph,
* and placing the selection on it.
*/
removeBlockEntity(editorState, entityKey, blockKey) {
let newState = editorState;
const content = editorState.getCurrentContent();
const blockMap = content.getBlockMap();
const block = blockMap.get(blockKey);
const newBlock = block.merge({
type: BLOCK_TYPE.UNSTYLED,
text: "",
// No text = no character list
characterList: block.getCharacterList().slice(0, 0),
data: {},
});
const newSelection = new draftJs.SelectionState({
anchorKey: blockKey,
focusKey: blockKey,
anchorOffset: 0,
focusOffset: 0,
});
const newContent = content.merge({
blockMap: blockMap.set(blockKey, newBlock),
});
newState = draftJs.EditorState.push(newState, newContent, "change-block-type");
newState = draftJs.EditorState.forceSelection(newState, newSelection);
return newState;
},
/**
* Handles pressing delete within an atomic block. This can happen when selection is placed on an image.
* Ideally this should be handled by the built-in RichUtils, but it's not.
* See https://github.com/wagtail/wagtail/issues/4370.
*/
handleDeleteAtomic(editorState) {
const selection = editorState.getSelection();
const content = editorState.getCurrentContent();
const key = selection.getAnchorKey();
const offset = selection.getAnchorOffset();
const block = content.getBlockForKey(key);
// Problematic selection. Pressing delete here would remove the entity, but not the block.
if (selection.isCollapsed() &&
block.getType() === BLOCK_TYPE.ATOMIC &&
offset === 0) {
return this.removeBlockEntity(editorState, block.getEntityAt(0), key);
}
return false;
},
/**
* Get an entity decorator strategy based on the given entity type.
* This strategy will find all entities of the given type.
*/
getEntityTypeStrategy(entityType) {
const strategy = (block, callback, contentState) => {
block.findEntityRanges((character) => {
const entityKey = character.getEntity();
return (entityKey !== null &&
contentState.getEntity(entityKey).getType() === entityType);
}, callback);
};
return strategy;
},
/**
* Inserts new unstyled block.
* Initially inspired from https://github.com/jpuri/draftjs-utils/blob/e81c0ae19c3b0fdef7e0c1b70d924398956be126/js/block.js#L153,
* but changed so that the split + block type reset amounts to
* only one change in the undo stack.
*/
insertNewUnstyledBlock(editorState) {
const selection = editorState.getSelection();
let newContent = draftJs.Modifier.splitBlock(editorState.getCurrentContent(), selection);
const blockMap = newContent.getBlockMap();
const blockKey = selection.getStartKey();
const insertedBlockKey = newContent.getKeyAfter(blockKey);
const newBlock = blockMap
.get(insertedBlockKey)
.set("type", BLOCK_TYPE.UNSTYLED);
newContent = newContent.merge({
blockMap: blockMap.set(insertedBlockKey, newBlock),
});
return draftJs.EditorState.push(editorState, newContent, "split-block");
},
/**
* Handles Shift + Enter keypress removing selection and inserting a line break.
* https://github.com/jpuri/draftjs-utils/blob/112bbe449cc9156522fcf2b40f2910a071b795c2/js/block.js#L133
*/
addLineBreak(editorState) {
const content = editorState.getCurrentContent();
const selection = editorState.getSelection();
if (selection.isCollapsed()) {
return draftJs.RichUtils.insertSoftNewline(editorState);
}
let newContent = draftJs.Modifier.removeRange(content, selection, "forward");
const fragment = newContent.getSelectionAfter();
const block = newContent.getBlockForKey(fragment.getStartKey());
newContent = draftJs.Modifier.insertText(newContent, fragment, "\n", block.getInlineStyleAt(fragment.getStartOffset()), undefined);
return draftJs.EditorState.push(editorState, newContent, "insert-fragment");
},
/**
* Handles hard newlines.
* https://github.com/jpuri/draftjs-utils/blob/e81c0ae19c3b0fdef7e0c1b70d924398956be126/js/keyPress.js#L17
*/
handleHardNewline(editorState) {
const selection = editorState.getSelection();
if (!selection.isCollapsed()) {
return false;
}
const content = editorState.getCurrentContent();
const blockKey = selection.getStartKey();
const block = content.getBlockForKey(blockKey);
const blockType = block.getType();
// Use a loose check to allow custom list item types to reuse the continuation behavior.
const isListBlock = blockType.endsWith("-list-item");
if (!isListBlock &&
block.getType() !== BLOCK_TYPE.UNSTYLED &&
block.getLength() === selection.getStartOffset()) {
return this.insertNewUnstyledBlock(editorState);
}
if (isListBlock && block.getLength() === 0) {
const depth = block.getDepth();
if (depth === 0) {
const nextContent = draftJs.RichUtils.tryToRemoveBlockStyle(editorState);
// At the moment, tryToRemoveBlockStyle always returns for
// collapsed selections at the start of a block. So in theory this corner case should never happen.
return nextContent
? draftJs.EditorState.push(editorState, nextContent, "change-block-type")
: false;
}
const blockMap = content.getBlockMap();
const newBlock = block.set("depth", depth - 1);
return draftJs.EditorState.push(editorState, content.merge({
blockMap: blockMap.set(blockKey, newBlock),
}), "adjust-depth");
}
return false;
},
/**
* Handles three scenarios:
* - Soft newlines.
* - Hard newlines in the "defer breaking out of the block" case.
* - Other hard newlines.
* See https://github.com/springload/draftail/issues/104,
* https://github.com/jpuri/draftjs-utils/issues/10.
*/
handleNewLine(editorState, event) {
// https://github.com/jpuri/draftjs-utils/blob/e81c0ae19c3b0fdef7e0c1b70d924398956be126/js/keyPress.js#L64
if (isSoftNewlineEvent__default["default"](event)) {
return this.addLineBreak(editorState);
}
const content = editorState.getCurrentContent();
const selection = editorState.getSelection();
const key = selection.getStartKey();
const offset = selection.getStartOffset();
const block = content.getBlockForKey(key);
const isDeferredBreakoutBlock = block.getType() === BLOCK_TYPE.CODE;
if (isDeferredBreakoutBlock) {
const isEmpty = selection.isCollapsed() && offset === 0 && block.getLength() === 0;
if (isEmpty) {
return draftJs.EditorState.push(editorState, draftJs.Modifier.setBlockType(content, selection, BLOCK_TYPE.UNSTYLED), "change-block-type");
}
return false;
}
return this.handleHardNewline(editorState);
},
getCommandPalettePrompt(editorState) {
const selection = editorState.getSelection();
const isCollapsed = selection.isCollapsed();
// No prompt if the selection isnβt collapsed.
if (!isCollapsed || !selection.getHasFocus()) {
return null;
}
const block = this.getSelectedBlock(editorState);
const focusOffset = selection.getFocusOffset();
const blockText = block.getText().slice(0, focusOffset);
const slashPos = blockText.lastIndexOf("/");
const spaceAfterSlash = blockText.length > slashPos + 1 ? blockText[slashPos + 1] === " " : false;
// No prompt if there is no slash or a space right after.
if (slashPos === -1 || spaceAfterSlash) {
return null;
}
const hasPromptStartOfLine = slashPos === 0 && (blockText.match(/\s/g) || []).length < 2;
if (hasPromptStartOfLine) {
return { text: blockText, block, position: slashPos };
}
const afterSlashText = blockText.slice(slashPos);
const hasPrompt = (afterSlashText.match(/\s/g) || []).length < 1;
if (hasPrompt) {
return { text: afterSlashText, block, position: slashPos };
}
return null;
},
removeCommandPalettePrompt(editorState) {
const prompt = this.getCommandPalettePrompt(editorState);
if (!prompt) {
return editorState;
}
const selection = editorState.getSelection();
const promptSelection = selection.merge({
anchorOffset: prompt?.position,
});
const nextContent = draftJs.Modifier.replaceText(editorState.getCurrentContent(), promptSelection, "");
return draftJs.EditorState.push(editorState, nextContent, "remove-range");
},
};
const getControlLabel = (type, config) => {
const predefinedType = type;
if (typeof config === "boolean") {
return LABELS[predefinedType];
}
if (typeof config.label === "string" || config.label === null) {
return config.label;
}
if (typeof config.icon !== "undefined") {
return null;
}
return LABELS[predefinedType];
};
const getControlDescription = (control) => {
const predefinedType = control.type;
const useDefaultDescription = typeof control.description === "undefined";
const defaultDescription = DESCRIPTIONS[predefinedType];
const description = useDefaultDescription
? defaultDescription
: control.description;
const useDefaultLabel = typeof control.label === "undefined";
const defaultLabel = LABELS[predefinedType];
const label = useDefaultLabel ? defaultLabel : control.label;
return description || label;
};
const getControlSearchFields = (control) => [
control.label || "",
control.description || "",
control.type ? LABELS[control.type] : "",
control.type ? DESCRIPTIONS[control.type] : "",
control.type || "",
];
const showControl = (config) => Boolean(config.icon) || Boolean(getControlLabel(config.type, config));
const showControlDesc = (config) => showControl(config) || Boolean(getControlDescription(config));
const { hasCommandModifier, isOptionKeyCommand } = draftJs.KeyBindingUtil;
const hasCmd = hasCommandModifier;
// Hack relying on the internals of Draft.js.
// See https://github.com/facebook/draft-js/pull/869
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-expect-error
const IS_MAC_OS = isOptionKeyCommand({ altKey: "test" }) === "test";
/**
* Methods defining the behavior of the editor, depending on its configuration.
*/
var behavior = {
/**
* Configure block render map from block types list.
*/
getBlockRenderMap(blockTypes) {
let renderMap = draftJs.DefaultDraftBlockRenderMap;
// Override default element for code block.
// Fix https://github.com/facebook/draft-js/issues/406.
if (blockTypes.some((block) => block.type === BLOCK_TYPE.CODE)) {
renderMap = renderMap.set(BLOCK_TYPE.CODE, {
element: "code",
wrapper: draftJs.DefaultDraftBlockRenderMap.get(BLOCK_TYPE.CODE).wrapper,
});
}
blockTypes
.filter((block) => block.element)
.forEach((block) => {
renderMap = renderMap.set(block.type, {
element: block.element,
});
});
return renderMap;
},
/**
* block style function automatically adding a class with the block's type.
*/
blockStyleFn(block) {
const type = block.getType();
const isEmpty = block.getText().length === 0;
return `Draftail-block--${type}${isEmpty ? " Draftail-block--empty" : ""} ${draftjsConductor.blockDepthStyleFn(block)}`;
},
/**
* Configure key binding function from enabled blocks, styles, entities.
*/
getKeyBindingFn(blockTypes, inlineStyles, entityTypes) {
const getEnabled = (activeTypes) => activeTypes.reduce((enabled, type) => {
enabled[type.type] = type.type;
return enabled;
}, {});
const blocks = getEnabled(blockTypes);
const styles = getEnabled(inlineStyles);
const entities = getEnabled(entityTypes);
// Emits key commands to use in `handleKeyCommand` in `Editor`.
const keyBindingFn = (e) => {
// Safeguard that we only trigger shortcuts with exact matches.
// eg. cmd + shift + b should not trigger bold.
if (e.shiftKey) {
// Key bindings supported by Draft.js must be explicitely discarded.
// See https://github.com/facebook/draft-js/issues/941.
switch (e.keyCode) {
case KEY_CODES.B:
return undefined;
case KEY_CODES.I:
return undefined;
case KEY_CODES.J:
return undefined;
case KEY_CODES.U:
return undefined;
case KEY_CODES.X:
return hasCmd(e) && styles[INLINE_STYLE.STRIKETHROUGH];
case KEY_CODES[7]:
return hasCmd(e) && blocks[BLOCK_TYPE.ORDERED_LIST_ITEM];
case KEY_CODES[8]:
return hasCmd(e) && blocks[BLOCK_TYPE.UNORDERED_LIST_ITEM];
default:
return draftJs.getDefaultKeyBinding(e);
}
}
const ctrlAlt = (e.ctrlKey || e.metaKey) && e.altKey;
switch (e.keyCode) {
case KEY_CODES.K:
return hasCmd(e) && entities.LINK;
case KEY_CODES.B:
return hasCmd(e) && styles[INLINE_STYLE.BOLD];
case KEY_CODES.I:
return hasCmd(e) && styles[INLINE_STYLE.ITALIC];
case KEY_CODES.J:
return hasCmd(e) && styles[INLINE_STYLE.CODE];
case KEY_CODES.U:
return hasCmd(e) && styles[INLINE_STYLE.UNDERLINE];
case KEY_CODES["."]:
return hasCmd(e) && styles[INLINE_STYLE.SUPERSCRIPT];
case KEY_CODES[","]:
return hasCmd(e) && styles[INLINE_STYLE.SUBSCRIPT];
case KEY_CODES[0]:
// Reverting to unstyled block is always available.
return ctrlAlt && BLOCK_TYPE.UNSTYLED;
case KEY_CODES[1]:
return ctrlAlt && blocks[BLOCK_TYPE.HEADER_ONE];
case KEY_CODES[2]:
return ctrlAlt && blocks[BLOCK_TYPE.HEADER_TWO];
case KEY_CODES[3]:
return ctrlAlt && blocks[BLOCK_TYPE.HEADER_THREE];
case KEY_CODES[4]:
return ctrlAlt && blocks[BLOCK_TYPE.HEADER_FOUR];
case KEY_CODES[5]:
return ctrlAlt && blocks[BLOCK_TYPE.HEADER_FIVE];
case KEY_CODES[6]:
return ctrlAlt && blocks[BLOCK_TYPE.HEADER_SIX];
default:
return draftJs.getDefaultKeyBinding(e);
}
};
return keyBindingFn;
},
hasKeyboardShortcut(type) {
return !!KEYBOARD_SHORTCUTS[type];
},
getKeyboardShortcut(type, isMacOS = IS_MAC_OS) {
const shortcut = KEYBOARD_SHORTCUTS[type];
const system = isMacOS ? "macOS" : "other";
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
return (shortcut && shortcut[system]) || shortcut;
},
/**
* Defines whether a block should be altered to a new type when
* the user types a given mark.
* This powers the "autolist" feature.
*
* Returns the new block type, or false if no replacement should occur.
*/
handleBeforeInputBlockType(mark, blockTypes) {
const knownMark = mark;
return blockTypes.find((b) => b.type === INPUT_BLOCK_MAP[knownMark])
? INPUT_BLOCK_MAP[knownMark]
: false;
},
handleBeforeInputHR(mark, block) {
return (mark === INPUT_ENTITY_MAP[ENTITY_TYPE.HORIZONTAL_RULE] &&
block.getType() !== BLOCK_TYPE.CODE);
},
/**
* Checks whether a given input string contains style shortcuts.
* If so, returns the range onto which the shortcut is applied.
*/
handleBeforeInputInlineStyle(input, inlineStyles) {
const activeShortcuts = INPUT_STYLE_MAP.filter(({ type }) => inlineStyles.some((s) => s.type === type));
let range;
const match = activeShortcuts.find(({ regex }) => {
// Re-create a RegExp object every time because RegExp is stateful.
range = new RegExp(regex, "g").exec(input);
return range;
});
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
return range && match
? {
pattern: match.pattern,
start: range.index === 0 ? 0 : range.index + 1,
end: range.index + range[0].length,
type: match.type,
}
: false;
},
getCustomStyleMap(inlineStyles) {
const customStyleMap = {};
inlineStyles.forEach((style) => {
if (style.style) {
customStyleMap[style.type] = style.style;
}
else if (CUSTOM_STYLE_MAP[style.type]) {
customStyleMap[style.type] =
CUSTOM_STYLE_MAP[style.type];
}
else {
customStyleMap[style.type] = {};
}
});
return customStyleMap;
},
/**
* Applies whitelist and blacklist operations to the editor content,
* so the resulting editor state is shaped according to Draftail
* expectations and configuration.
*/
filterPaste({ maxListNesting, enableHorizontalRule, enableLineBreak, blockTypes, inlineStyles, entityTypes, }, editorState) {
const enabledEntityTypes = entityTypes.slice();
const whitespacedCharacters = ["\t", "π·"];
if (enableHorizontalRule) {
enabledEntityTypes.push({
type: ENTITY_TYPE.HORIZONTAL_RULE,
});
}
if (!enableLineBreak) {
whitespacedCharacters.push("\n");
}
return draftjsFilters.filterEditorState({
blocks: blockTypes.map((b) => b.type),
styles: inlineStyles.map((s) => s.type),
entities: enabledEntityTypes,
maxNesting: maxListNesting,
whitespacedCharacters,
}, editorState);
},
getCommandPalette({ commands, blockTypes, entityTypes, enableHorizontalRule, }) {
if (!commands) {
return [];
}
if (typeof commands === "boolean" && commands) {
const items = [
...blockTypes.filter(showControlDesc).map((t) => ({
...t,
category: "blockTypes",
})),
...entityTypes
.filter((t) => showControlDesc(t))
.map((t) => ({
...t,
category: "entityTypes",
})),
];
if (enableHorizontalRule) {
items.push({
type: ENTITY_TYPE.HORIZONTAL_RULE,
...(typeof enableHorizontalRule === "object"
? enableHorizontalRule
: {}),
category: "entityTypes",
});
}
return [
{
label: null,
type: "built-ins",
items,
},
];
}
return commands.map((category) => {
let items = category.items || [];
if (category.type === "blockTypes") {
const rawItems = category.items || blockTypes;
items = rawItems.filter(showControlDesc).map((t) => ({
...t,
category: "blockTypes",
}));
}
else if (category.type === "entityTypes") {
const rawItems = category.items || entityTypes;
items = rawItems
.filter((t) => showControlDesc(t))
.map((t) => ({
...t,
category: "entityTypes",
}));
}
return {
...category,
items,
};
});
},
};
/**
* Icon as SVG element. Can optionally render a React element instead.
*/
const Icon = ({ icon, title, className }) => {
let children;
if (typeof icon === "string") {
if (icon.includes("#")) {
children = jsxRuntime.jsx("use", { xlinkHref: icon });
}
else {
children = jsxRuntime.jsx("path", { d: icon });
}
}
else if (Array.isArray(icon)) {
// eslint-disable-next-line react/no-array-index-key
children = icon.map((d, i) => jsxRuntime.jsx("path", { d: d }, i));
}
else {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-expect-error
return icon;
}
return (jsxRuntime.jsx("svg", { width: "16", height: "16", viewBox: "0 0 1024 1024", className: `Draftail-Icon ${className || ""}`, "aria-hidden": title ? undefined : "true", role: title ? "img" : undefined, "aria-label": title || undefined, children: children }));
};
/**
* Displays a basic button, with optional active variant,
* enriched with a tooltip. The tooltip stops showing on click.
*/
class ToolbarButton extends React.PureComponent {
constructor(props) {
super(props);
this.state = {
showTooltipOnHover: true,
};
this.onMouseDown = this.onMouseDown.bind(this);
this.onMouseLeave = this.onMouseLeave.bind(this);
}
onMouseDown(e) {
const { name, onClick } = this.props;
e.preventDefault();
this.setState({
showTooltipOnHover: false,
});
if (onClick) {
onClick(name || "");
}
}
onMouseLeave() {
this.setState({
showTooltipOnHover: true,
});
}
render() {
const { name, active, label, title, icon, className, tooltipDirection } = this.props;
const { showTooltipOnHover } = this.state;
const showTooltip = title && showTooltipOnHover;
return (jsxRuntime.jsxs("button", { name: name, className: `Draftail-ToolbarButton ${className || ""}${active ? " Draftail-ToolbarButton--active" : ""}`, type: "button", "aria-label": title || undefined, "data-draftail-balloon": showTooltip ? tooltipDirection || "up" : null, tabIndex: -1, onMouseDown: this.onMouseDown, onMouseLeave: this.onMouseLeave, children: [icon ? jsxRuntime.jsx(Icon, { icon: icon }) : null, label ? (jsxRuntime.jsx("span", { className: "Draftail-ToolbarButton__label", children: label })) : null] }));
}
}
const ToolbarGroup = ({ name, children }) => {
const hasChildren = React__default["default"].Children.toArray(children).some((c) => c !== null);
return hasChildren ? (jsxRuntime.jsx("div", { className: `Draftail-ToolbarGroup Draftail-ToolbarGroup--${name}`, children: children })) : null;
};
const getButtonTitle = (type, config) => {
const description = typeof config === "boolean" || typeof config.description === "undefined"
? DESCRIPTIONS[type]
: config.description;
const hasShortcut = behavior.hasKeyboardShortcut(type);
let title = description;
if (hasShortcut) {
const desc = description ? `${description}\n` : "";
title = `${desc}${behavior.getKeyboardShortcut(type)}`;
}
return title;
};
class ToolbarDefaults extends React.PureComponent {
render() {
const { currentStyles, currentBlock, blockTypes, inlineStyles, enableHorizontalRule, enableLineBreak, showUndoControl, showRedoControl, entityTypes, toggleBlockType, toggleInlineStyle, addHR, addBR, onUndoRedo, onRequestSource, } = this.props;
return [
jsxRuntime.jsx(ToolbarGroup, { name: "styles", children: inlineStyles.filter(showControl).map((t) => (jsxRuntime.jsx(ToolbarButton, { name: t.type, active: currentStyles.has(t.type), label: getControlLabel(t.type, t), title: getButtonTitle(t.type, t), icon: t.icon, onClick: toggleInlineStyle }, t.type))) }, "styles"),
jsxRuntime.jsx(ToolbarGroup, { name: "blocks", children: blockTypes.filter(showControl).map((t) => (jsxRuntime.jsx(ToolbarButton, { name: t.type, active: currentBlock === t.type, label: getControlLabel(t.type, t), title: getButtonTitle(t.type, t), icon: t.icon, onClick: toggleBlockType }, t.type))) }, "blocks"),
jsxRuntime.jsxs(ToolbarGroup, { name: "hr-br", children: [enableHorizontalRule ? (jsxRuntime.jsx(ToolbarButton, { name: ENTITY_TYPE.HORIZONTAL_RULE, onClick: addHR, label: getControlLabel(ENTITY_TYPE.HORIZONTAL_RULE, enableHorizontalRule), title: getButtonTitle(ENTITY_TYPE.HORIZONTAL_RULE, enableHorizontalRule), icon: typeof enableHorizontalRule !== "boolean"
? enableHorizontalRule.icon
: null })) : null, enableLineBreak ? (jsxRuntime.jsx(ToolbarButton, { name: BR_TYPE, onClick: addBR, label: getControlLabel(BR_TYPE, enableLineBreak), title: getButtonTitle(BR_TYPE, enableLineBreak), icon: typeof enableLineBreak !== "boolean" ? enableLineBreak.icon : null })) : null] }, "hr-br"),
jsxRuntime.jsx(ToolbarGroup, { name: "entities", children: entityTypes.filter(showControl).map((t) => (jsxRuntime.jsx(ToolbarButton, { name: t.type, onClick: onRequestSource, label: getControlLabel(t.type, t), title: getButtonTitle(t.type, t), icon: t.icon }, t.type))) }, "entities"),
jsxRuntime.jsxs(ToolbarGroup, { name: "undo-redo", children: [showUndoControl ? (jsxRuntime.jsx(ToolbarButton, { name: UNDO_TYPE, onClick: onUndoRedo, label: getControlLabel(UNDO_TYPE, showUndoControl), title: getButtonTitle(UNDO_TYPE, showUndoControl), icon: typeof showUndoControl !== "boolean" ? showUndoControl.icon : null })) : null, showRedoControl ? (jsxRuntime.jsx(ToolbarButton, { name: REDO_TYPE, onClick: onUndoRedo, label: getControlLabel(REDO_TYPE, showRedoControl), title: getButtonTitle(REDO_TYPE, showRedoControl), icon: typeof showRedoControl !== "boolean" ? showRedoControl.icon : null })) : null] }, "undo-redo"),
];
}
}
const Toolbar = ({ controls, getEditorState, onChange, className, ...otherProps }) => (jsxRuntime.jsxs("div", { className: `Draftail-Toolbar ${className || ""}`, role: "toolbar", children: [jsxRuntime.jsx(ToolbarDefaults, { ...otherProps }), jsxRuntime.jsx(ToolbarGroup, { name: "controls", children: controls.map((control, i) => {
if (control.meta) {
return null;
}
const Control = control.block || control.inline || control;
return (jsxRuntime.jsx(Control
// eslint-disable-next-line react/no-array-index-key
, { getEditorState: getEditorState, onChange: onChange }, i));
}) })] }));
/**
* Generates CSS styles for list items, for a given depth.
*/
function Styles$1({ max }) {
return max ? jsxRuntime.jsx("style", { children: draftjsConductor.getListNestingStyles(max) }) : null;
}
const ListNestingStyles = React__default["default"].memo(Styles$1);
/**
* An <hr/> in the editor.
*/
const DividerBlock = () => jsxRuntime.jsx("hr", { className: "Draftail-DividerBlock" });
// See https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/Collator/Collator.
const collator = new Intl.Collator(undefined, {
usage: "search",
sensitivity: "base",
ignorePunctuation: true,
});
/**
* See https://github.com/adobe/react-spectrum/blob/70e769acf639fc4ef3a704cb8fad81349cb4137a/packages/%40react-aria/i18n/src/useFilter.ts#L57.
* See also https://github.com/arty-name/locale-index-of,
* and https://github.com/tc39/ecma402/issues/506.
*/
const contains = (string, substring) => {
if (substring.length === 0) {
return true;
}
const haystack = string.normalize("NFC");
const needle = substring.normalize("NFC");
for (let scan = 0; scan + needle.length <= haystack.length; scan += 1) {
const slice = haystack.slice(scan, scan + needle.length);
if (collator.compare(needle, slice) === 0) {
return true;
}
}
return false;
};
/**
* Find all items where the label or description matches the inputValue.
*/
const findMatches = (items, getSearchFields, input) => items.filter((item) => {
const matches = getSearchFields(item);
return matches.some((match) => match && contains(match, input));
});
/**
* A generic ComboBox component, intended to be reusable outside of Draftail.
*/
function ComboBox({ label, placeholder, inputValue, items, getItemLabel, getItemDescription, getSearchFields, onSelect, noResultsText, }) {
// If there is no label defined, assume the editor serves as the input field.
const inlineCombobox = !label;
const flatItems = items.flatMap((category) => category.items || []);
const [inputItems, setInputItems] = React.useState(flatItems);
const noResults = inputItems.length === 0;
const { getLabelProps, getMenuProps, getInputProps, getItemProps, setHighlightedIndex, setInputValue, openMenu, } = downshift.useCombobox({
...(typeof inputValue !== "undefined" && { inputValue }),
initialInputValue: inputValue || "",
items: inputItems,
itemToString(item) {
if (!item) {
return "";
}
return getItemDescription(item) || getItemLabel(item.type, item) || "";
},
selectedItem: null,
onSelectedItemChange: onSelect,
onInputValueChange: (changes) => {
const { inputValue: val } = changes;
if (!val) {
setInputItems(flatItems);
return;
}
const filtered = findMatches(flatItems, getSearchFields, val);
setInputItems(filtered);
// Always reset the first item to highlighted on filtering, to speed up selection.
setHighlightedIndex(0);
},
});
React.useEffect(() => {
if (inputValue) {
openM