strapi-plugin-content-manager
Version:
A powerful UI to easily manage your data.
820 lines (727 loc) • 25.8 kB
JavaScript
/**
*
* Wysiwyg
*
*/
import React from 'react';
import {
ContentState,
EditorState,
getDefaultKeyBinding,
genKey,
Modifier,
RichUtils,
SelectionState,
} from 'draft-js';
import PropTypes from 'prop-types';
import { isEmpty, isNaN, replace, words } from 'lodash';
import cn from 'classnames';
import WysiwygProvider from '../../containers/WysiwygProvider';
import Controls from '../WysiwygInlineControls';
import PreviewWysiwyg from '../PreviewWysiwyg';
import WysiwygBottomControls from '../WysiwygBottomControls';
import WysiwygEditor from '../WysiwygEditor';
import MediaLib from './MediaLib';
import CustomSelect from './customSelect';
import PreviewControl from './previewControl';
import ToggleMode from './toggleMode';
import { CONTROLS } from './constants';
import {
getBlockContent,
getBlockStyle,
getDefaultSelectionOffsets,
getKeyCommandData,
getOffSets,
} from './helpers';
import {
createNewBlock,
getNextBlocksList,
getSelectedBlocksList,
onTab,
updateSelection,
} from './utils';
import EditorWrapper from './EditorWrapper';
/* eslint-disable */
class Wysiwyg extends React.Component {
constructor(props) {
super(props);
this.state = {
editorState: EditorState.createEmpty(),
isFocused: false,
isFullscreen: false,
isMediaLibraryOpened: false,
isPreviewMode: false,
headerValue: '',
selection: null,
};
this.focus = () => {
this.setState({ isFocused: true });
return this.domEditor.focus();
};
this.blur = () => {
this.setState({ isFocused: false });
return this.domEditor.blur();
};
}
componentDidMount() {
if (this.props.autoFocus) {
this.focus();
}
if (!isEmpty(this.props.value)) {
this.setInitialValue(this.props);
}
}
shouldComponentUpdate(nextProps, nextState) {
if (nextProps.value !== this.props.value && !this.state.isFocused) {
return true;
}
if (nextState.editorState !== this.state.editorState) {
return true;
}
if (nextProps.resetProps !== this.props.resetProps) {
return true;
}
if (nextState.isFocused !== this.state.isFocused) {
return true;
}
if (nextState.isFullscreen !== this.state.isFullscreen) {
return true;
}
if (nextState.isPreviewMode !== this.state.isPreviewMode) {
return true;
}
if (nextState.headerValue !== this.state.headerValue) {
return true;
}
if (nextProps.error !== this.props.error) {
return true;
}
if (nextState.isMediaLibraryOpened !== this.state.isMediaLibraryOpened) {
return true;
}
return false;
}
componentDidUpdate(prevProps) {
// Handle resetProps
if (prevProps.resetProps !== this.props.resetProps) {
this.setInitialValue(this.props);
}
// Update the content when used in a dynamiczone
// We cannot update the value of the component each time there is a onChange event
// fired otherwise the component gets very slow
if (prevProps.value !== this.props.value && !this.state.isFocused) {
this.setInitialValue(this.props);
}
// Here we need to update the content of editorState for the edition case
// With the current architecture of the EditView we cannot rely on the componentDidMount lifecycle
// Since we need to perform some operations in the reducer the loading phase stops before all the operations
// are computed which in some case causes the inputs component to be initialised with a null value.
if (!prevProps.value && this.props.value) {
// This is also called if the first thing you add in the editor is
// a markdown formatting block (b, i, u, etc.) which results in
// the selection being pushed to the end after the first character is added.
// Basically, setInitialValue is always called whenever
// you start typing in an empty editor (even after the initial load)
this.setInitialValue(this.props);
}
}
/**
* Init the editor with data from
* @param {[type]} props [description]
*/
setInitialValue = props => {
if (isEmpty(props.value)) {
return this.setState({ editorState: EditorState.createEmpty() });
}
const contentState = ContentState.createFromText(props.value);
const newEditorState = EditorState.createWithContent(contentState);
const editorState = this.state.isFocused
? EditorState.moveFocusToEnd(newEditorState)
: newEditorState;
return this.setState({ editorState });
};
/**
* Handler to add B, I, Strike, U, link
* @param {String} content usually something like **textToReplace**
* @param {String} style
*/
addContent = (content, style) => {
const selectedText = this.getSelectedText();
// Retrieve the associated data for the type to add
const { innerContent, endReplacer, startReplacer } = getBlockContent(style);
// Replace the selected text by the markdown command or insert default text
const defaultContent =
selectedText === ''
? replace(content, 'textToReplace', innerContent)
: replace(content, 'textToReplace', selectedText);
// Get the current cursor position
const cursorPosition = getOffSets(this.getSelection()).start;
const textWithEntity = this.modifyBlockContent(defaultContent);
// Highlight the text
const { anchorOffset, focusOffset } = getDefaultSelectionOffsets(
defaultContent,
startReplacer,
endReplacer,
cursorPosition
);
// Merge the current selection with the new one
const updatedSelection = this.getSelection().merge({
anchorOffset,
focusOffset,
});
const newEditorState = EditorState.push(
this.getEditorState(),
textWithEntity,
'insert-character'
);
if (selectedText.length === 0) {
this.setState(
{
// Highlight the text if the selection was empty
editorState: EditorState.forceSelection(newEditorState, updatedSelection),
},
() => {
this.focus();
// Update the parent reducer
}
);
this.sendData(newEditorState);
return;
}
// Don't handle selection: the user has selected some text to be changed with the appropriate markdown
this.setState(
{
editorState: newEditorState,
},
() => {
this.focus();
}
);
this.sendData(newEditorState);
return;
};
/**
* Create an ordered list block
* @return ContentBlock
*/
addOlBlock = () => {
// Get all the selected blocks
const selectedBlocksList = getSelectedBlocksList(this.getEditorState());
let newEditorState = this.getEditorState();
// Check if the cursor is NOT at the beginning of a new line
// So we need to move all the next blocks
if (getOffSets(this.getSelection()).start !== 0) {
// Retrieve all the blocks after the current position
const nextBlocks = getNextBlocksList(newEditorState, this.getSelection().getStartKey());
let liNumber = 1;
// Loop to update each block after the inserted li
nextBlocks.map((block, index) => {
const previousContent =
index === 0
? this.getEditorState()
.getCurrentContent()
.getBlockForKey(this.getCurrentAnchorKey())
: newEditorState.getCurrentContent().getBlockBefore(block.getKey());
// Check if there was an li before the position so we update the entire list bullets
const number = previousContent ? parseInt(previousContent.getText().split('.')[0], 10) : 0;
liNumber = isNaN(number) ? 1 : number + 1;
const nextBlockText = index === 0 ? `${liNumber}. ` : nextBlocks.get(index - 1).getText();
// Update the current block
const newBlock = createNewBlock(nextBlockText, 'block-list', block.getKey());
// Update the contentState
const newContentState = this.createNewContentStateFromBlock(
newBlock,
newEditorState.getCurrentContent()
);
newEditorState = EditorState.push(newEditorState, newContentState);
});
// Move the cursor to the correct position and add a space after '.'
// 2 for the dot and the space after, we add the number length (10 = offset of 2)
const offset = 2 + liNumber.toString().length;
const updatedSelection = updateSelection(this.getSelection(), nextBlocks, offset);
return this.setState({
editorState: EditorState.acceptSelection(newEditorState, updatedSelection),
});
}
// If the cursor is at the beginning we need to move all the content after the cursor so we don't loose the data
selectedBlocksList.map((block, i) => {
const selectedText = block.getText();
const li = selectedText === '' ? `${i + 1}. ` : `${i + 1}. ${selectedText}`;
const newBlock = createNewBlock(li, 'block-list', block.getKey());
const newContentState = this.createNewContentStateFromBlock(
newBlock,
newEditorState.getCurrentContent()
);
newEditorState = EditorState.push(newEditorState, newContentState);
});
// Update the parent reducer
this.sendData(newEditorState);
return this.setState({
editorState: EditorState.moveFocusToEnd(newEditorState),
});
};
/**
* Create an unordered list
* @return ContentBlock
*/
// NOTE: it's pretty much the same dynamic as above
// We don't use the same handler because it needs less logic than a ordered list
// so it's easier to maintain the code
addUlBlock = () => {
const selectedBlocksList = getSelectedBlocksList(this.getEditorState());
let newEditorState = this.getEditorState();
if (getOffSets(this.getSelection()).start !== 0) {
const nextBlocks = getNextBlocksList(newEditorState, this.getSelection().getStartKey());
nextBlocks.map((block, index) => {
const nextBlockText = index === 0 ? '- ' : nextBlocks.get(index - 1).getText();
const newBlock = createNewBlock(nextBlockText, 'block-list', block.getKey());
const newContentState = this.createNewContentStateFromBlock(
newBlock,
newEditorState.getCurrentContent()
);
newEditorState = EditorState.push(newEditorState, newContentState);
});
const updatedSelection = updateSelection(this.getSelection(), nextBlocks, 2);
return this.setState({
editorState: EditorState.acceptSelection(newEditorState, updatedSelection),
});
}
selectedBlocksList.map(block => {
const selectedText = block.getText();
const li = selectedText === '' ? '- ' : `- ${selectedText}`;
const newBlock = createNewBlock(li, 'block-list', block.getKey());
const newContentState = this.createNewContentStateFromBlock(
newBlock,
newEditorState.getCurrentContent()
);
newEditorState = EditorState.push(newEditorState, newContentState);
});
this.sendData(newEditorState);
return this.setState({
editorState: EditorState.moveFocusToEnd(newEditorState),
});
};
/**
* Handler to create header
* @param {String} text header content
*/
addBlock = text => {
const nextBlockKey = this.getNextBlockKey(this.getCurrentAnchorKey()) || genKey();
const newBlock = createNewBlock(text, 'header', nextBlockKey);
const newContentState = this.createNewContentStateFromBlock(newBlock);
const newEditorState = this.createNewEditorState(newContentState, text);
this.sendData(newEditorState);
return this.setState(
{
editorState: newEditorState,
},
() => {
this.focus();
}
);
};
addLinks = data => {
const links = data.reduce((acc, { alt, url }) => `${acc}\n`, '');
const { selection } = this.state;
const newBlock = createNewBlock(links);
const newContentState = this.createNewContentStateFromBlock(newBlock);
const anchorOffset = links.length;
const focusOffset = links.length;
let newEditorState = this.createNewEditorState(newContentState, links);
const updatedSelection =
getOffSets(selection).start === 0
? this.getSelection().merge({ anchorOffset, focusOffset })
: new SelectionState({
anchorKey: newBlock.getKey(),
anchorOffset,
focusOffset,
focusKey: newBlock.getKey(),
isBackward: false,
});
newEditorState = EditorState.forceSelection(newEditorState, updatedSelection);
this.setState({ isFocused: true });
this.sendData(newEditorState);
return this.setState({
editorState: newEditorState,
});
};
/**
* Handler used for code block and Img controls
* @param {String} content the text that will be added
* @param {String} style the type
*/
addSimpleBlockWithSelection = (content, style) => {
// Retrieve the selected text by the user
const selectedText = this.getSelectedText();
const { innerContent, endReplacer, startReplacer } = getBlockContent(style);
const defaultContent =
selectedText === ''
? replace(content, 'textToReplace', innerContent)
: replace(content, 'textToReplace', selectedText);
const newBlock = createNewBlock(defaultContent);
const newContentState = this.createNewContentStateFromBlock(newBlock);
const { anchorOffset, focusOffset } = getDefaultSelectionOffsets(
defaultContent,
startReplacer,
endReplacer
);
let newEditorState = this.createNewEditorState(newContentState, defaultContent);
const updatedSelection =
getOffSets(this.getSelection()).start === 0
? this.getSelection().merge({ anchorOffset, focusOffset })
: new SelectionState({
anchorKey: newBlock.getKey(),
anchorOffset,
focusOffset,
focusKey: newBlock.getKey(),
isBackward: false,
});
newEditorState = EditorState.acceptSelection(newEditorState, updatedSelection);
return this.setState(
{
editorState: EditorState.forceSelection(newEditorState, newEditorState.getSelection()),
},
() => {
this.focus();
// Update the parent reducer
this.sendData(newEditorState);
}
);
};
/**
* Update the current editorState
* @param {Map} newContentState
* @param {String} text The text to add
* @return {Map} EditorState
*/
createNewEditorState = (newContentState, text) => {
let newEditorState;
if (getOffSets(this.getSelection()).start !== 0) {
newEditorState = EditorState.push(this.getEditorState(), newContentState);
} else {
const textWithEntity = this.modifyBlockContent(text);
newEditorState = EditorState.push(this.getEditorState(), textWithEntity, 'insert-characters');
}
return newEditorState;
};
/**
* Update the content of a block
* @param {Map} newBlock The new block
* @param {Map} contentState The ContentState
* @return {Map} The updated block
*/
createNewBlockMap = (newBlock, contentState) =>
contentState.getBlockMap().set(newBlock.key, newBlock);
createNewContentStateFromBlock = (
newBlock,
contentState = this.getEditorState().getCurrentContent()
) =>
ContentState.createFromBlockArray(this.createNewBlockMap(newBlock, contentState).toArray())
.set('selectionBefore', contentState.getSelectionBefore())
.set('selectionAfter', contentState.getSelectionAfter());
getCharactersNumber = (editorState = this.getEditorState()) => {
const plainText = editorState.getCurrentContent().getPlainText();
const spacesNumber = plainText.split(' ').length;
return words(plainText).join('').length + spacesNumber - 1;
};
getEditorState = () => this.state.editorState;
/**
* Retrieve the selected text
* @return {Map}
*/
getSelection = () => this.getEditorState().getSelection();
/**
* Retrieve the cursor anchor key
* @return {String}
*/
getCurrentAnchorKey = () => this.getSelection().getAnchorKey();
/**
* Retrieve the current content block
* @return {Map} ContentBlock
*/
getCurrentContentBlock = () =>
this.getEditorState()
.getCurrentContent()
.getBlockForKey(this.getSelection().getAnchorKey());
/**
* Retrieve the block key after a specific one
* @param {String} currentBlockKey
* @param {Map} [editorState=this.getEditorState()] The current EditorState or the updated one
* @return {String} The next block key
*/
getNextBlockKey = (currentBlockKey, editorState = this.getEditorState()) =>
editorState.getCurrentContent().getKeyAfter(currentBlockKey);
getSelectedText = ({ start, end } = getOffSets(this.getSelection())) =>
this.getCurrentContentBlock()
.getText()
.slice(start, end);
handleBlur = () => {
const target = {
name: this.props.name,
type: 'textarea',
value: this.getEditorState()
.getCurrentContent()
.getPlainText(),
};
this.props.onBlur({ target });
this.blur();
};
handleChangeSelect = ({ target }) => {
this.setState({ headerValue: target.value });
const selectedText = this.getSelectedText();
const title = selectedText === '' ? `${target.value} ` : `${target.value} ${selectedText}`;
this.addBlock(title);
return this.setState({ headerValue: '' });
};
handleClickPreview = () => this.setState({ isPreviewMode: !this.state.isPreviewMode });
/**
* Handler that listens for specific key commands
* @param {String} command
* @param {Map} editorState
* @return {Bool}
*/
handleKeyCommand = (command, editorState) => {
const newState = RichUtils.handleKeyCommand(editorState, command);
if (command === 'bold' || command === 'italic' || command === 'underline') {
const { content, style } = getKeyCommandData(command);
this.addContent(content, style);
return false;
}
if (newState && command !== 'backspace') {
this.onChange(newState);
return true;
}
return false;
};
handleOpenMediaLibrary = () => {
return this.setState({
isMediaLibraryOpened: true,
isFullscreen: false,
selection: this.getSelection(),
});
};
handleReturn = (e, editorState) => {
const selection = editorState.getSelection();
const currentBlock = editorState.getCurrentContent().getBlockForKey(selection.getStartKey());
if (currentBlock.getText().split('')[0] === '-') {
this.addUlBlock();
return true;
}
if (
currentBlock.getText().split('.').length > 1 &&
!isNaN(parseInt(currentBlock.getText().split('.')[0], 10))
) {
this.addOlBlock();
return true;
}
return false;
};
mapKeyToEditorCommand = e => {
if (e.keyCode === 9 /* TAB */) {
const newEditorState = RichUtils.onTab(e, this.state.editorState, 4 /* maxDepth */);
if (newEditorState !== this.state.editorState) {
this.onChange(newEditorState);
}
return;
}
return getDefaultKeyBinding(e);
};
/**
* Change the content of a block
* @param {String]} text
* @param {Map} [contentState=this.getEditorState().getCurrentContent()]
* @return {Map}
*/
modifyBlockContent = (text, contentState = this.getEditorState().getCurrentContent()) =>
Modifier.replaceText(contentState, this.getSelection(), text);
onChange = editorState => {
const { disabled } = this.props;
if (!disabled) {
this.sendData(editorState);
this.setState({ editorState });
}
};
handleTab = e => {
e.preventDefault();
const newEditorState = onTab(this.getEditorState());
return this.onChange(newEditorState);
};
/**
* Toggle the medialibrary modal
*/
handleToggle = () => {
this.setState(prevState => ({
...prevState,
isMediaLibraryOpened: !prevState.isMediaLibraryOpened,
}));
};
/**
* Update the parent reducer
* @param {Map} editorState [description]
*/
sendData = editorState => {
if (
this.getEditorState().getCurrentContent() !== editorState.getCurrentContent() ||
editorState.getLastChangeType() === 'remove-range'
) {
this.props.onChange({
target: {
value: editorState.getCurrentContent().getPlainText(),
name: this.props.name,
type: 'textarea',
},
});
} else return;
};
toggleFullScreen = e => {
e.preventDefault();
this.setState({
isFullscreen: !this.state.isFullscreen,
isPreviewMode: false,
});
};
render() {
const { editorState, isMediaLibraryOpened, isPreviewMode, isFullscreen } = this.state;
const editorStyle = isFullscreen ? { marginTop: '0' } : this.props.style;
const { disabled } = this.props;
return (
<WysiwygProvider
handleChangeSelect={this.handleChangeSelect}
headerValue={this.state.headerValue}
html={this.props.value}
isPreviewMode={this.state.isPreviewMode}
isFullscreen={this.state.isFullscreen}
placeholder={this.props.placeholder}
>
<EditorWrapper isFullscreen={isFullscreen} disabled={disabled}>
{/* FIRST EDITOR WITH CONTROLS} */}
<div
className={cn(
'editorWrapper',
!this.props.deactivateErrorHighlight && this.props.error && 'editorError',
!isEmpty(this.props.className) && this.props.className
)}
onClick={e => {
if (isFullscreen) {
e.preventDefault();
e.stopPropagation();
}
}}
style={editorStyle}
>
<div className="controlsContainer">
<CustomSelect disabled={isPreviewMode || disabled} />
{CONTROLS.map((value, key) => (
<Controls
key={key}
buttons={value}
disabled={isPreviewMode || disabled}
editorState={editorState}
handlers={{
addContent: this.addContent,
addOlBlock: this.addOlBlock,
addSimpleBlockWithSelection: this.addSimpleBlockWithSelection,
addUlBlock: this.addUlBlock,
handleOpenMediaLibrary: this.handleOpenMediaLibrary,
}}
onToggle={this.toggleInlineStyle}
onToggleBlock={this.toggleBlockType}
/>
))}
{!isFullscreen ? (
<ToggleMode isPreviewMode={isPreviewMode} onClick={this.handleClickPreview} />
) : (
<div style={{ marginRight: '10px' }} />
)}
</div>
{/* WYSIWYG PREVIEW NOT FULLSCREEN */}
{isPreviewMode ? (
<PreviewWysiwyg data={this.props.value} />
) : (
<div
className={cn('editor', isFullscreen && 'editorFullScreen')}
onClick={this.focus}
>
<WysiwygEditor
blockStyleFn={getBlockStyle}
editorState={editorState}
handleKeyCommand={this.handleKeyCommand}
handleReturn={this.handleReturn}
keyBindingFn={this.mapKeyToEditorCommand}
onBlur={this.handleBlur}
onChange={this.onChange}
onTab={this.handleTab}
placeholder={this.props.placeholder}
setRef={editor => (this.domEditor = editor)}
stripPastedStyles
tabIndex={this.props.tabIndex}
spellCheck
/>
<input className="editorInput" tabIndex="-1" />
</div>
)}
{!isFullscreen && (
<WysiwygBottomControls
isPreviewMode={isPreviewMode}
onClick={this.toggleFullScreen}
onChange={this.handleDrop}
/>
)}
</div>
{/* PREVIEW WYSIWYG FULLSCREEN */}
{isFullscreen && (
<div
className={cn('editorWrapper')}
onClick={e => {
e.preventDefault();
e.stopPropagation();
}}
style={{ marginTop: '0' }}
>
<PreviewControl
onClick={this.toggleFullScreen}
characters={this.getCharactersNumber()}
/>
<PreviewWysiwyg data={this.props.value} />
</div>
)}
</EditorWrapper>
<MediaLib
onToggle={this.handleToggle}
isOpen={isMediaLibraryOpened}
onChange={this.addLinks}
/>
</WysiwygProvider>
);
}
}
Wysiwyg.defaultProps = {
autoFocus: false,
className: '',
deactivateErrorHighlight: false,
disabled: false,
error: false,
onBlur: () => {},
onChange: () => {},
placeholder: '',
resetProps: false,
style: {},
tabIndex: '0',
value: '',
};
Wysiwyg.propTypes = {
autoFocus: PropTypes.bool,
className: PropTypes.string,
deactivateErrorHighlight: PropTypes.bool,
disabled: PropTypes.bool,
error: PropTypes.bool,
name: PropTypes.string.isRequired,
onBlur: PropTypes.func,
onChange: PropTypes.func,
placeholder: PropTypes.string,
resetProps: PropTypes.bool,
style: PropTypes.object,
tabIndex: PropTypes.string,
value: PropTypes.string,
};
export default Wysiwyg;