scripts/core/editor3/reducers/toolbar.tsx
import {RichUtils, EditorState, ContentState, SelectionState, convertToRaw, EntityInstance} from 'draft-js';
import * as entityUtils from '../components/links/entityUtils';
import {onChange} from './editor3';
import * as Links from '../helpers/links';
import * as Blocks from '../helpers/blocks';
import * as Highlights from '../helpers/highlights';
import {removeFormatFromState, removeAllFormatAndStyles} from '../helpers/removeFormat';
import {insertEntity} from '../helpers/draftInsertEntity';
import {IEditorStore} from '../store';
import {assertNever} from 'core/helpers/typescript-helpers';
import {ITextCase} from '../actions';
import {PopupTypes} from '../actions/popups';
import {getCell, getData, IEditor3TableData, setData} from '../helpers/table';
import {processCells} from './table';
import {ILink} from '../components/links/LinkInput';
import {CustomEditor3Entity} from '../constants';
/**
* @description Contains the list of toolbar related reducers.
*/
const toolbar = (state: IEditorStore, action) => {
switch (action.type) {
case 'TOOLBAR_TOGGLE_BLOCK_STYLE':
return toggleBlockStyle(state, action.payload);
case 'TOOLBAR_TOGGLE_INLINE_STYLE':
return toggleInlineStyle(state, action.payload);
case 'TOOLBAR_APPLY_LINK':
return applyLink(state, action.payload);
case 'TOOLBAR_APPLY_LINK_MULTI-LINE_QUOTE':
return applyLinkToMultiLineQuote(state, action.payload);
case 'TOOLBAR_REMOVE_LINK':
return removeLink(state);
case 'TOOLBAR_REMOVE_LINK_MULTI-LINE_QUOTE':
return removeLinkInMultiLineQuote(state);
case 'TOOLBAR_REMOVE_FORMAT':
return removeFormat(state);
case 'TOOLBAR_REMOVE_ALL_FORMAT':
return removeAllFormat(state);
case 'TOOLBAR_INSERT_MEDIA':
return insertMedia(state, action.payload);
case 'TOOLBAR_UPDATE_IMAGE':
return updateImage(state, action.payload);
case 'TOOLBAR_REMOVE_BLOCK':
return removeBlock(state, action.payload);
case 'TOOLBAR_SET_POPUP':
return setPopup(state, action.payload);
case 'TOOLBAR_TOGGLE_INVISIBLES':
return toggleInvisibles(state);
case 'CHANGE_CASE':
return changeCase(state, action.payload);
case 'UNDO':
return onChange(state, EditorState.undo(state.editorState));
case 'REDO':
return onChange(state, EditorState.redo(state.editorState));
case 'SET_CUSTOM_TOOLBAR' :
return setCustomToolbar(state, action.payload);
case 'SET_MULTI-LINE_QUOTE_POPUP' :
return setMultiLinePopup(state, action.payload);
default:
return state;
}
};
/**
* @ngdoc method
* @name toggleBlockStyle
* @param {string} blockStyle
* @description Applies the given block style.
*/
const toggleBlockStyle = (state, blockType) => {
const {editorState} = state;
const stateAfterChange = RichUtils.toggleBlockType(
editorState,
blockType,
);
return onChange(state, stateAfterChange);
};
/**
* @ngdoc method
* @name toggleInlineStyle
* @param {string} inlineStyle
* @description Applies the given inline style.
*/
const toggleInlineStyle = (state, inlineStyle) => {
const {editorState} = state;
let stateAfterChange = RichUtils.toggleInlineStyle(
editorState,
inlineStyle,
);
// Check if there was a suggestions to toggle that style
stateAfterChange = handleExistingInlineStyleSuggestionOnToggle(stateAfterChange, inlineStyle);
return onChange(state, stateAfterChange);
};
const handleExistingInlineStyleSuggestionOnToggle = (editorState, inlineStyle) => {
const type = Highlights.getTypeByInlineStyle(inlineStyle);
const highlightName = Highlights.getHighlightStyleAtCurrentPosition(editorState, type);
if (highlightName) {
return Highlights.removeHighlight(editorState, highlightName);
}
return editorState;
};
/**
* @ngdoc method
* @name applyLink
* @param {Object} link Link data to apply
* @param {Entity|null} entity The entity to apply the URL too.
* @description Applies the given URL to the current content selection. If an
* entity is specified, it applies the link to that entity instead.
*/
const applyLink = (state, {link, entity}) => {
let {editorState} = state;
if (entity) {
return onChange(state, entityUtils.replaceSelectedEntityData(editorState, {link}), true);
}
editorState = Links.createLink(editorState, link);
return onChange(state, editorState);
};
function applyChangesToTableCell(
state: IEditorStore,
operation: (editorState: EditorState) => EditorState,
): IEditorStore {
const {activeCell, editorState: mainEditorState} = state;
if (activeCell === null) {
return state;
}
const {i, j, key, currentStyle, selection} = activeCell;
const contentState = mainEditorState.getCurrentContent();
const block = contentState.getBlockForKey(key);
const data = getData(contentState, block.getKey());
const cellEditorState = getCell(data, i, j, currentStyle, selection);
const editorStateNext = operation(cellEditorState);
const dataNew: IEditor3TableData = {
...data,
cells: [[convertToRaw(editorStateNext.getCurrentContent())]],
};
const newMainState = setData(mainEditorState, block, dataNew, 'change-block-data');
return onChange(state, newMainState, true);
}
/**
* Applies the given URL to the current content selection in multi-line quote block.
* If the selection is a link, it applies the link to the entity instead.
*/
const applyLinkToMultiLineQuote = (state, {link, entity}: {link: ILink, entity: EntityInstance}) =>
applyChangesToTableCell(state, (editorState) =>
entity
? entityUtils.replaceSelectedEntityData(editorState, {link})
: Links.createLink(editorState, link),
);
/**
* Removes the link on the entire entity under the cursor in multi-line quote block.
*/
const removeLinkInMultiLineQuote = (state) => {
const {activeCell, editorState: mainEditorState} = state;
if (activeCell === null) {
return state;
}
const {i, j, key, currentStyle, selection} = activeCell;
const contentState = mainEditorState.getCurrentContent();
const block = contentState.getBlockForKey(key);
const data = getData(contentState, block.getKey());
const cellEditorStateWithRemovedLink =
Links.removeLink(getCell(data, i, j, currentStyle, selection));
const newData: IEditor3TableData = {
...data.data,
cells: [[convertToRaw(cellEditorStateWithRemovedLink.getCurrentContent())]],
};
const editorState = setData(mainEditorState, block, newData, 'change-block-data');
return onChange(state, editorState);
};
/**
* @ngdoc method
* @name removeLink
* @description Removes the link on the entire entity under the cursor.
*/
const removeLink = (state) => {
let {editorState} = state;
editorState = Links.removeLink(editorState);
return onChange(state, editorState);
};
/*
* Converts the state in plain text
*/
const removeAllFormat = (state) => {
return onChange(state, removeAllFormatAndStyles(state.editorState));
};
/*
* @ngdoc method
* @name removeFormat
* @description Set all blocks in selection to unstyled except atomic blocks
* and remove inline styles
*/
const removeFormat = (state) => {
const {editorState} = state;
const selection = editorState.getSelection();
const stateWithoutFormat = removeFormatFromState(editorState);
const newSelection = selection.merge({
anchorOffset: selection.getEndOffset(),
anchorKey: selection.getEndKey(),
focusOffset: selection.getEndOffset(),
focusKey: selection.getEndKey(),
isBackward: false,
hasFocus: true,
});
return onChange(state, EditorState.acceptSelection(stateWithoutFormat, newSelection));
};
/**
* @ngdoc method
* @name insertMedia
* @param {Array} files List of media files to be inserted into document.
* @param {String} targetBlockKey Block key where we want to insert the media
* @description Inserts a list of media files into the document.
*/
const insertMedia = (state, {files = [], targetBlockKey = null}) => {
let {editorState} = state;
files.forEach((file) => {
editorState = addMedia(editorState, file, targetBlockKey);
});
return onChange(state, editorState);
};
/**
* @ngdoc method
* @name addMedia
* @param {Object} editorState Editor state to add the media to.
* @param {Object} media Media data.
* @param {String} targetBlockKey Block key where the media is going to
* @returns {Object} New editor state with media inserted as atomic block.
* @description Inserts the given media into the given editor state's content and returns
* the updated editor state.
*/
export const addMedia = (editorState: EditorState, media, targetBlockKey = null): EditorState =>
insertEntity(editorState, CustomEditor3Entity.MEDIA, 'MUTABLE', {media}, targetBlockKey);
export const addArticleEmbed = (editorState: EditorState, data, targetBlockKey = null): EditorState =>
insertEntity(editorState, CustomEditor3Entity.ARTICLE_EMBED, 'MUTABLE', data, targetBlockKey);
/**
* @ngdoc method
* @name updateImage
* @param {Object} data Contains the entityKey and the new image data.
* @description Updates the given entityKey with the new image data.
*/
const updateImage = (state, {entityKey, media}) => {
const {editorState} = state;
const selection = editorState.getSelection();
const contentState = editorState.getCurrentContent();
const newContentState = contentState.replaceEntityData(entityKey, {media});
const newEditorState = EditorState.push(editorState, newContentState, 'change-block-data');
// focus the editor and softly force a refresh
const newState = EditorState.forceSelection(newEditorState, selection);
const entityDataHasChanged = true;
return onChange(state, newState, entityDataHasChanged);
};
/**
* @ngdoc method
* @name removeBlock
* @param {Object} data Contains the key from of block to remove
* @description Removes block from editor
*/
const removeBlock = (state, {blockKey}) => {
const {editorState} = state;
const newEditorState = Blocks.removeBlock(editorState, blockKey);
return onChange(state, newEditorState);
};
/**
* @ngdoc method
* @name toggleInvisibles
* @param {Object} state
* @return {Object} returns new state
* @description Enable/Disable the paragraph marks
*/
const toggleInvisibles = (state) => {
const {invisibles} = state;
return {
...state,
invisibles: !invisibles,
};
};
/**
* @ngdoc method
* @name setPopup
* @param {Object} data Type of popup and popup data.
* @description Sets the toolbar popup to the given type.
*/
const setPopup = (state: IEditorStore, {type, data}) => {
const {editorState} = state;
let newEditorState = editorState;
// SDESK-3885
// Whenever we hide a popup, the ContentState is not changed so it will
// trigger these two DraftJS events:
// * editOnFocus
// * editOnSelect
// The first one renders the right selection but the second one uses
// global `window.getSelection()` (which doesn't exist, as the editor lost focus)
// to check if the editorState selection matches that one. As it doesn't, it renders
// the selection in the first character of the first block.
// Using `forceSelection` won't trigger those events and the selection will be correct.
if (type === PopupTypes.Hidden) {
newEditorState = EditorState.forceSelection(editorState, editorState.getSelection());
}
return {...state, editorState: newEditorState, popup: {type, data}};
};
type PopupType = keyof typeof PopupTypes;
const setMultiLinePopup = (state: IEditorStore, {type, data}: {type: PopupType, data: SelectionState }) =>
processCells(
state,
(cells, numCols, numRows, i, j, withHeader, currentStyle, selection) => {
const newData = {cells, numRows, numCols, withHeader};
return {
...state,
popup: {type, data},
data: newData,
};
},
);
function changeCase(state: IEditorStore, payload: {changeTo: ITextCase, selection: SelectionState}) {
const getChangedText = (text: string) => {
if (changeTo === 'uppercase') {
return text.toUpperCase();
} else if (changeTo === 'lowercase') {
return text.toLowerCase();
} else {
assertNever(changeTo);
}
};
const {selection, changeTo} = payload;
const contentState = state.editorState.getCurrentContent();
let ended = false;
let started = false;
const startKey = selection.getStartKey();
const endKey = selection.getEndKey();
const nextBlockMap = state.editorState.getCurrentContent().getBlockMap().map((block) => {
const key = block.getKey();
const from = key === startKey ? selection.getStartOffset() : 0;
const to = key === endKey ? selection.getEndOffset() : block.getLength();
if (key === startKey) {
started = true;
}
if (ended === true || started === false) {
return block;
}
const text = block.getText();
const before = text.slice(0, from);
const toReplace = text.slice(from, to);
const after = text.slice(to, block.getLength());
if (key === endKey) {
ended = true;
}
return block.merge({
text: before + getChangedText(toReplace) + after,
});
}) as ContentState;
const nextContentState = contentState.merge({
blockMap: nextBlockMap,
}) as ContentState;
const nextEditorState = EditorState.push(state.editorState, nextContentState, 'spellcheck-change');
return onChange(state, EditorState.forceSelection(nextEditorState, selection));
}
const setCustomToolbar = (state: IEditorStore, style: IEditorStore['customToolbarStyle']): IEditorStore => ({
...state,
customToolbarStyle: style,
});
export default toolbar;