kedao
Version:
Rich Text Editor Based On Draft.js
497 lines (496 loc) • 24.5 kB
JavaScript
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
return new (P || (P = Promise))(function (resolve, reject) {
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
step((generator = generator.apply(thisArg, _arguments || [])).next());
});
};
import { classNameParser } from '../utils/style';
import React, { useEffect, useState, useRef, useMemo, useCallback } from 'react';
import { toggleSelectionIndent, insertHorizontalLine, undo, redo, insertHTML, increaseSelectionIndent, getSelectedBlocks, insertMedias, removeBlock, getSelectionBlock, removeSelectionInlineStyles, getSelectionBlockType, decreaseSelectionIndent, toggleSelectionBlockType, insertText, handleNewLine, handleKeyCommand as defaultHandleKeyCommand, clear, convertRawToEditorState, convertHTMLToEditorState, convertEditorStateToHTML } from '../utils';
import { Editor, RichUtils, Modifier, EditorState, getDefaultKeyBinding, KeyBindingUtil, ContentState } from 'draft-js';
import mergeClassNames from 'merge-class-names';
import { Provider as JotaiProvider, useSetAtom } from 'jotai';
import { getBlockRendererFn, getBlockRenderMap, getBlockStyleFn, getCustomStyleMap, getCustomStyleFn, getDecorators } from '../utils/renderers';
import ControlBar from '../components/ControlBar';
import 'draft-js/dist/Draft.css';
import styles from "./style.module.css";
import getFragmentFromSelection from 'draft-js/lib/getFragmentFromSelection';
import { defaultControls, defaultFontFamilies, defaultImageControls } from '../constants';
import { langAtom } from '../states';
const cls = classNameParser(styles);
const langLoaders = {
en: () => __awaiter(void 0, void 0, void 0, function* () { return (yield import('../i18n/en')).default; }),
jpn: () => __awaiter(void 0, void 0, void 0, function* () { return (yield import('../i18n/jpn')).default; }),
kr: () => __awaiter(void 0, void 0, void 0, function* () { return (yield import('../i18n/kr')).default; }),
pl: () => __awaiter(void 0, void 0, void 0, function* () { return (yield import('../i18n/pl')).default; }),
ru: () => __awaiter(void 0, void 0, void 0, function* () { return (yield import('../i18n/ru')).default; }),
tr: () => __awaiter(void 0, void 0, void 0, function* () { return (yield import('../i18n/tr')).default; }),
'zh-hant': () => __awaiter(void 0, void 0, void 0, function* () { return (yield import('../i18n/zh-hant')).default; }),
zh: () => __awaiter(void 0, void 0, void 0, function* () { return (yield import('../i18n/zh')).default; })
};
const defaultMedia = {
pasteImage: true,
imagePasteLimit: 5,
image: true,
video: true,
audio: true,
uploadFn: null,
validateFn: null,
onBeforeDeselect: null,
onDeselect: null,
onBeforeSelect: null,
onSelect: null,
onBeforeRemove: null,
onRemove: null,
onCancel: null,
onFileSelect: null,
onBeforeInsert: null,
onInsert: null,
onChange: null,
accepts: {
image: 'image/png,image/jpeg,image/gif,image/webp,image/apng,image/svg',
video: 'video/mp4',
audio: 'audio/mp3'
},
externals: {
audio: true,
video: true,
image: true,
embed: true
}
};
const unitExportFn = (value, type) => type === 'line-height' ? value : `${value}px`;
export const createStateFromContent = (content, options = {}) => {
const customOptions = Object.assign({}, options);
customOptions.unitExportFn = customOptions.unitExportFn || unitExportFn;
let editorState = null;
if (content instanceof EditorState) {
editorState = content;
}
if (typeof content === 'object' &&
content &&
content.blocks &&
content.entityMap) {
editorState = convertRawToEditorState(content, getDecorators());
}
if (typeof content === 'string') {
try {
if (/^(-)?\d+$/.test(content)) {
editorState = convertHTMLToEditorState(content, getDecorators(), customOptions, 'create');
}
else {
editorState = createStateFromContent(JSON.parse(content), customOptions);
}
}
catch (error) {
editorState = convertHTMLToEditorState(content, getDecorators(), customOptions, 'create');
}
}
else if (typeof content === 'number') {
editorState = convertHTMLToEditorState(Number(content)
.toLocaleString()
.replace(/,/g, ''), getDecorators(), customOptions, 'create');
}
else {
editorState = EditorState.createEmpty(getDecorators());
}
return editorState;
};
const defaultExtendControls = [];
const defaultExtendAtomics = [];
const defaultExcludeControls = [];
const defaultConverts = { unitExportFn };
const KedaoEditor = ({ controls = defaultControls, language: locale = 'zh', excludeControls = defaultExcludeControls, handlePastedText, extendControls = defaultExtendControls, extendAtomics = defaultExtendAtomics, componentBelowControlBar = null, media = defaultMedia, imageControls = defaultImageControls, imageResizable = true, imageEqualRatio = true, codeTabIndents = 2, textBackgroundColor = true, allowInsertLinkText = false, converts = defaultConverts, stripPastedStyles = false, className = '', handleKeyCommand, onSave, onBlur, onDelete, onFocus, handleReturn, handleBeforeInput, handleDroppedFiles, blockStyleFn, blockRendererFn, customStyleMap, handlePastedFiles, blockRenderMap, customStyleFn, onFullscreen, placeholder, readOnly, disabled, style, controlBarClassName = '', controlBarStyle, contentClassName = '', contentStyle, defaultValue, value, editorId, keyBindingFn, id, onChange, fixPlaceholder = false }) => {
var _a;
const draftInstanceRef = useRef(null);
const [editorLocked, setEditorLocked] = useState(null);
const editorDecoratorsRef = useRef(getDecorators());
const controlBarInstanceRef = useRef(null);
const [isLiving, setIsLiving] = useState(false);
const setLanuage = useSetAtom(langAtom);
const [mode, setMode] = useState('richtext');
const [html, setHtml] = useState('');
const defaultEditorState = useMemo(() => (defaultValue || value) instanceof EditorState
? defaultValue || value
: EditorState.createEmpty(editorDecoratorsRef.current), []);
const [editorState, setEditorState] = useState(defaultEditorState);
const [isFullscreen, setIsFullscreen] = useState(false);
const containerRef = useRef(null);
useEffect(() => {
const setupLang = () => __awaiter(void 0, void 0, void 0, function* () {
const loader = langLoaders[locale];
if (loader) {
try {
const language = yield loader();
setLanuage(language);
}
catch (error) {
console.error(error);
}
}
});
setupLang().catch(console.error);
}, [locale]);
const getConvertOptions = () => {
const result = Object.assign(Object.assign({ unitExportFn }, converts), { fontFamilies: defaultFontFamilies });
return result;
};
const convertOptions = useMemo(getConvertOptions, [editorId, id, converts]);
useEffect(() => {
if (isLiving) {
setEditorState(value);
}
}, [value]);
useEffect(() => {
setIsLiving(true);
return () => {
setIsLiving(false);
};
}, []);
const handleChange = useCallback((editorState, callback) => {
let newEditorState = editorState;
if (!(editorState instanceof EditorState)) {
newEditorState = EditorState.set(editorState, {
decorator: editorDecoratorsRef.current
});
}
setEditorState(newEditorState);
onChange === null || onChange === void 0 ? void 0 : onChange(newEditorState);
callback === null || callback === void 0 ? void 0 : callback(newEditorState);
}, [editorDecoratorsRef.current, onChange]);
const setValue = useCallback((editorState, callback) => {
return handleChange(editorState, callback);
}, [handleChange]);
const forceRender = () => {
const selectionState = editorState.getSelection();
setValue(EditorState.set(editorState, {
decorator: editorDecoratorsRef.current
}), () => {
setValue(EditorState.forceSelection(editorState, selectionState));
});
};
const keyCommandHandlers = useCallback((command, editorState) => {
if ((handleKeyCommand === null || handleKeyCommand === void 0 ? void 0 : handleKeyCommand(command, editorState)) === 'handled') {
return 'handled';
}
if (command === 'kedao-save') {
onSave === null || onSave === void 0 ? void 0 : onSave(editorState);
return 'handled';
}
const allowIndent = controls.some(ctrl => {
return typeof ctrl === 'string'
? ctrl === 'text-indent'
: ctrl.type === 'text-indent';
}) && !excludeControls.includes('text-indent');
const cursorStart = editorState.getSelection().getStartOffset();
const cursorEnd = editorState.getSelection().getEndOffset();
const cursorIsAtFirst = cursorStart === 0 && cursorEnd === 0;
if (command === 'backspace') {
if (onDelete && !(onDelete === null || onDelete === void 0 ? void 0 : onDelete(editorState))) {
return 'handled';
}
const blockType = getSelectionBlockType(editorState);
if (allowIndent && cursorIsAtFirst && blockType !== 'code-block') {
setValue(decreaseSelectionIndent(editorState));
}
}
if (command === 'tab') {
const blockType = getSelectionBlockType(editorState);
if (blockType === 'code-block') {
setValue(insertText(editorState, ' '.repeat(codeTabIndents)));
return 'handled';
}
if (blockType === 'ordered-list-item' ||
blockType === 'unordered-list-item') {
const newEditorState = RichUtils.onTab(event, editorState, 4);
if (newEditorState !== editorState) {
setValue(newEditorState);
}
return 'handled';
}
if (blockType !== 'atomic' && allowIndent && cursorIsAtFirst) {
setValue(increaseSelectionIndent(editorState));
return 'handled';
}
}
const nextEditorState = defaultHandleKeyCommand(editorState, command);
if (nextEditorState) {
setValue(nextEditorState);
return 'handled';
}
return 'not-handled';
}, [setValue, handleKeyCommand, onSave, onDelete, controls, excludeControls, codeTabIndents]);
const handleFocus = useCallback(() => {
onFocus === null || onFocus === void 0 ? void 0 : onFocus(editorState);
}, [onFocus]);
const handleBlur = useCallback(() => {
onBlur === null || onBlur === void 0 ? void 0 : onBlur(editorState);
}, [onBlur]);
const requestFocus = useCallback(() => {
setTimeout(() => { var _a; return (_a = draftInstanceRef.current) === null || _a === void 0 ? void 0 : _a.focus(); }, 0);
}, [draftInstanceRef.current]);
const handleReturn_ = useCallback((event, editorState) => {
if ((handleReturn === null || handleReturn === void 0 ? void 0 : handleReturn(event, editorState)) === 'handled') {
return 'handled';
}
const currentBlock = getSelectionBlock(editorState);
const currentBlockType = currentBlock.getType();
if (currentBlockType === 'unordered-list-item' ||
currentBlockType === 'ordered-list-item') {
if (currentBlock.getLength() === 0) {
setValue(toggleSelectionBlockType(editorState, 'unstyled'));
return 'handled';
}
return 'not-handled';
}
if (currentBlockType === 'code-block') {
if (event.which === 13 &&
(event.getModifierState('Shift') ||
event.getModifierState('Alt') ||
event.getModifierState('Control'))) {
setValue(toggleSelectionBlockType(editorState, 'unstyled'));
return 'handled';
}
return 'not-handled';
}
if (currentBlockType === 'blockquote') {
if (event.which === 13) {
if (event.getModifierState('Shift') ||
event.getModifierState('Alt') ||
event.getModifierState('Control')) {
// eslint-disable-next-line no-param-reassign
event.which = 0;
}
else {
setValue(RichUtils.insertSoftNewline(editorState));
return 'handled';
}
}
}
const nextEditorState = handleNewLine(editorState, event);
if (nextEditorState) {
setValue(nextEditorState);
return 'handled';
}
return 'not-handled';
}, [setValue, handleReturn, handleNewLine]);
const handleBeforeInput_ = useCallback((chars, editorState) => {
if ((handleBeforeInput === null || handleBeforeInput === void 0 ? void 0 : handleBeforeInput(chars, editorState)) === 'handled') {
return 'handled';
}
return 'not-handled';
}, []);
const handleDrop = useCallback((selectionState, dataTransfer) => {
if (readOnly || disabled) {
return 'handled';
}
if (window.__KEDAO_DRAGING__IMAGE__) {
let nextEditorState = EditorState.forceSelection(editorState, selectionState);
nextEditorState = insertMedias(nextEditorState, [
window.__KEDAO_DRAGING__IMAGE__.mediaData
]);
nextEditorState = removeBlock(nextEditorState, window.__KEDAO_DRAGING__IMAGE__.block, nextEditorState.getSelection());
window.__KEDAO_DRAGING__IMAGE__ = null;
setEditorLocked(true);
setValue(nextEditorState);
return 'handled';
}
if (!dataTransfer || !dataTransfer.getText()) {
return 'handled';
}
return 'not-handled';
}, []);
const handleFiles = useCallback(files => {
const { pasteImage, validateFn, imagePasteLimit } = Object.assign(Object.assign({}, defaultMedia), media);
const upload = file => {
var _a;
(_a = controlBarInstanceRef.current) === null || _a === void 0 ? void 0 : _a.uploadImage(file, image => {
if (isLiving) {
setValue(insertMedias(editorState, [image]));
}
});
};
if (pasteImage) {
files.slice(0, imagePasteLimit).forEach(file => {
if (file &&
file.type.indexOf('image') > -1 &&
controlBarInstanceRef.current) {
const validateResult = validateFn ? validateFn(file) : true;
if (validateResult instanceof Promise) {
validateResult
.then(() => {
upload(file);
})
.catch(console.error);
}
else if (validateResult) {
upload(file);
}
}
});
}
if (files[0] && files[0].type.indexOf('image') > -1 && pasteImage) {
return 'handled';
}
return 'not-handled';
}, [!!controlBarInstanceRef.current, media, (_a = controlBarInstanceRef.current) === null || _a === void 0 ? void 0 : _a.uploadImage]);
const handleDroppedFiles_ = useCallback((selectionState, files) => {
if ((handleDroppedFiles === null || handleDroppedFiles === void 0 ? void 0 : handleDroppedFiles(selectionState, files)) === 'handled') {
return 'handled';
}
return handleFiles(files);
}, [handleFiles, handleDroppedFiles]);
const handlePastedFiles_ = useCallback(files => {
if ((handlePastedFiles === null || handlePastedFiles === void 0 ? void 0 : handlePastedFiles(files)) === 'handled') {
return 'handled';
}
return handleFiles(files);
}, [handleFiles, handlePastedFiles]);
const handleCopyContent = event => {
const blockMap = getFragmentFromSelection(editorState);
if (blockMap === null || blockMap === void 0 ? void 0 : blockMap.toArray) {
try {
const tempContentState = ContentState.createFromBlockArray(blockMap.toArray());
const tempEditorState = EditorState.createWithContent(tempContentState);
const clipboardData = event.clipboardData ||
window.clipboardData ||
event.originalEvent.clipboardData;
const html = convertEditorStateToHTML(tempEditorState, convertOptions);
const text = tempEditorState.getCurrentContent().getPlainText();
clipboardData.setData('text/html', html);
clipboardData.setData('text/plain', text);
event.preventDefault();
}
catch (error) {
console.warn(error);
}
}
};
const handlePastedText_ = useCallback((text, html, editorState) => {
if ((handlePastedText === null || handlePastedText === void 0 ? void 0 : handlePastedText(text, html, editorState)) === 'handled') {
return 'handled';
}
if (!html || stripPastedStyles) {
return 'not-handled';
}
setValue(insertHTML(editorState, convertOptions, html, 'paste'));
return 'handled';
}, [stripPastedStyles, setValue]);
const handleCompositionStart = () => {
const selectedBlocks = getSelectedBlocks(editorState);
if (selectedBlocks && selectedBlocks.length > 1) {
const nextEditorState = EditorState.push(editorState, Modifier.removeRange(editorState.getCurrentContent(), editorState.getSelection(), 'backward'), 'remove-range');
setValue(nextEditorState);
}
};
editorId = editorId || id;
controls = controls.filter(item => !excludeControls.includes(item));
const controlBarMedia = useMemo(() => {
const result = Object.assign(Object.assign(Object.assign({}, defaultMedia), media), { accepts: Object.assign(Object.assign({}, defaultMedia.accepts), media === null || media === void 0 ? void 0 : media.accepts) });
if (!result.uploadFn) {
result.video = false;
result.audio = false;
}
return result;
}, [media]);
const commands = useMemo(() => ({
undo: () => {
setValue(undo(editorState));
},
redo: () => {
setValue(redo(editorState));
},
removeSelectionInlineStyles: () => {
setValue(removeSelectionInlineStyles(editorState));
},
insertHorizontalLine: () => {
setValue(insertHorizontalLine(editorState));
},
clearEditorContent: () => {
setValue(clear(editorState), (editorState) => {
setValue(toggleSelectionIndent(editorState, 0));
});
},
toggleFullscreen: () => {
let newValue = null;
setIsFullscreen(v => {
newValue = !v;
return newValue;
});
onFullscreen === null || onFullscreen === void 0 ? void 0 : onFullscreen(newValue);
},
toggleHtml: () => {
setMode(oldMode => oldMode === 'html' ? 'richtext' : 'html');
setHtml(convertEditorStateToHTML(editorState));
}
}), [onFullscreen, editorState]);
const getContainerNode = useCallback(() => containerRef.current, [
containerRef.current
]);
const blockRendererFn_ = getBlockRendererFn({
value: editorState,
onChange: setValue,
readOnly: readOnly || disabled,
imageControls,
imageResizable,
imageEqualRatio,
getContainerNode,
refresh: () => forceRender(),
lock: setEditorLocked,
extendAtomics,
editorId
}, blockRendererFn);
const blockRenderMap_ = useMemo(() => getBlockRenderMap(blockRenderMap), [blockRenderMap]);
const blockStyleFn_ = useMemo(() => getBlockStyleFn(blockStyleFn), [blockStyleFn]);
const customStyleMap_ = useMemo(() => getCustomStyleMap(customStyleMap), [customStyleMap]);
const customStyleFn_ = useMemo(() => getCustomStyleFn({
fontFamilies: defaultFontFamilies,
unitExportFn,
customStyleFn: customStyleFn
}), [customStyleFn]);
const keyBindingFn_ = useCallback(event => {
if (event.keyCode === 83 &&
(KeyBindingUtil.hasCommandModifier(event) ||
KeyBindingUtil.isCtrlKeyCommand(event))) {
return 'kedao-save';
}
if (keyBindingFn) {
return keyBindingFn(event) || getDefaultKeyBinding(event);
}
return getDefaultKeyBinding(event);
}, [keyBindingFn]);
const mixedProps = {};
if (editorLocked || disabled || readOnly) {
mixedProps.readOnly = true;
}
if (placeholder &&
fixPlaceholder &&
!editorState.getCurrentContent().hasText() &&
editorState
.getCurrentContent()
.getFirstBlock()
.getType() !== 'unstyled') {
placeholder = '';
}
const memoControls = useMemo(() => controls, [controls === null || controls === void 0 ? void 0 : controls.join(',')]);
const renderEditor = () => {
if (mode === 'html') {
return (React.createElement("div", { className: cls('kedao-html-container') },
React.createElement("textarea", { value: html, readOnly: true, className: cls('kedao-html') })));
}
return (React.createElement(Editor, { ref: draftInstanceRef, editorState: editorState, handleKeyCommand: keyCommandHandlers, handleReturn: handleReturn_, handleBeforeInput: handleBeforeInput_, handleDrop: handleDrop, handleDroppedFiles: handleDroppedFiles_, handlePastedText: handlePastedText_, handlePastedFiles: handlePastedFiles_, onChange: handleChange, onFocus: handleFocus, onBlur: handleBlur, blockRenderMap: blockRenderMap_, blockRendererFn: blockRendererFn_, blockStyleFn: blockStyleFn_, customStyleMap: customStyleMap_, customStyleFn: customStyleFn_, keyBindingFn: keyBindingFn_, placeholder: placeholder, stripPastedStyles: stripPastedStyles, readOnly: editorLocked || disabled || readOnly }));
};
return (React.createElement("div", { style: style, ref: containerRef, className: cls(mergeClassNames('kedao-container', className, disabled && 'disabled', readOnly && 'read-only', isFullscreen && 'fullscreen')) },
React.createElement(ControlBar, { ref: controlBarInstanceRef, editorState: editorState, getContainerNode: getContainerNode, className: cls(controlBarClassName), style: controlBarStyle, editorId: editorId, media: controlBarMedia, controls: memoControls, extendControls: extendControls, textBackgroundColor: textBackgroundColor, allowInsertLinkText: allowInsertLinkText, isFullscreen: isFullscreen, onChange: setValue, onRequestFocus: requestFocus, commands: commands, mode: mode }),
componentBelowControlBar,
React.createElement("div", { onCompositionStart: handleCompositionStart, className: cls(`kedao-content ${contentClassName}`), onCopy: handleCopyContent, style: contentStyle }, renderEditor())));
};
const JotaiWrapper = props => {
return (React.createElement(JotaiProvider, null,
React.createElement(KedaoEditor, Object.assign({}, props))));
};
export { EditorState };
export default JotaiWrapper;