UNPKG

draftail

Version:

πŸ“πŸΈ A configurable rich text editor built with Draft.js

1,195 lines (1,178 loc) β€’ 102 kB
'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