Skip to content

Commit

Permalink
#3081: Make sure mandatory blocks are not merged or deleted
Browse files Browse the repository at this point in the history
This makes sure that if the RichTextEditor is using a template containing
mandatory blocks, we can not merge them with next/previous blocks using
delete/backspace and we can not delete them.
  • Loading branch information
maradragan committed Jul 29, 2020
1 parent e417c1b commit f246884
Showing 1 changed file with 125 additions and 31 deletions.
156 changes: 125 additions & 31 deletions client/src/components/editor/plugins/mandatoryBlockPlugin.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { EditorState, Modifier } from "draft-js"
import { EditorState, Modifier, SelectionState } from "draft-js"

const replaceWithPlaceholder = (
nextState,
Expand All @@ -19,6 +19,27 @@ const replaceWithPlaceholder = (
return EditorState.push(nextState, nextContentState, "replace-text")
}

const isMandatoryBlock = block => block?.data?.toObject()?.mandatory || false

const selectionHasMandatoryBlock = (selectionState, contentState) => {
const selectionStartKey = selectionState.getStartKey()
const selectionEndKey = selectionState.getEndKey()
const blockAfterSelection = contentState.getBlockAfter(selectionEndKey) || {}
let currentBlock = contentState.getBlockForKey(selectionStartKey)
let selectionHasMandatoryBlock = false
while (
currentBlock &&
currentBlock.key !== blockAfterSelection?.key &&
!selectionHasMandatoryBlock
) {
if (isMandatoryBlock(currentBlock)) {
selectionHasMandatoryBlock = true
}
currentBlock = contentState.getBlockAfter(currentBlock.key)
}
return selectionHasMandatoryBlock
}

const createMandatoryBlockPlugin = config => {
const blockStyleFn = contentBlock => {
const contentBlockData = contentBlock.getData().toObject()
Expand All @@ -27,67 +48,140 @@ const createMandatoryBlockPlugin = config => {
return "mandatory"
}
}

const handleReturn = (e, editorState) => {
const selectionState = editorState.getSelection()
const { anchorKey, focusKey } = selectionState
const startOffset = selectionState.getStartOffset()
const contentState = editorState.getCurrentContent()
const currentBlock = contentState.getBlockForKey(anchorKey)
const currentMandatory = isMandatoryBlock(currentBlock)
const isCollapsedSelection = selectionState.isCollapsed() // anchorOffset === focusOffset
if (currentMandatory && isCollapsedSelection && startOffset === 0) {
// Prevent return at the beginning of a mandatory block, it would result
// in an empty mandatory block and a non-mandatory block containing the text
// of the block which used to be mandatory.
return "handled"
}
if (
anchorKey !== focusKey &&
selectionHasMandatoryBlock(selectionState, contentState)
) {
// Prevent return when the selection contains at least one mandatory block,
// it would result in deleting the selection including the mandatory block.
return "handled"
}
}
const handleKeyCommand = (command, editorState, { setEditorState }) => {
// TODO: select several blocks, when next block after selection is mandatory;
// try to delete, it would not work as it would think it is the first case.
if (["backspace", "delete"].includes(command)) {
const selectionState = editorState.getSelection()
const anchorKey = selectionState.getAnchorKey()
const contentState = editorState.getCurrentContent()
const currentContentBlock = contentState.getBlockForKey(anchorKey)
const currentContentBlockData = currentContentBlock.getData().toObject()
const currentContentBlockText = currentContentBlock.getText()
const { mandatory, placeholder } = currentContentBlockData
const { anchorOffset, focusOffset } = selectionState
const { anchorKey, focusKey } = selectionState
const startOffset = selectionState.getStartOffset()
const endOffset = selectionState.getEndOffset()
const blockLength = currentContentBlockText.length
const isCollapsedSelection = selectionState.isCollapsed() // anchorOffset === focusOffset

const contentState = editorState.getCurrentContent()
const currentBlock = contentState.getBlockForKey(anchorKey)
const { mandatory: currentMandatory, placeholder } =
currentBlock?.data?.toObject() || {}
const currentBlockLength = currentBlock.getLength()

const previousBlock = contentState.getBlockBefore(
selectionState.getStartKey()
)

const nextBlock = contentState.getBlockAfter(selectionState.getEndKey())
const nextIsMandatory = isMandatoryBlock(nextBlock)

if (
anchorKey !== focusKey &&
selectionHasMandatoryBlock(selectionState, contentState)
) {
// Prevent deleting a selection of several blocks if at least one
// of them is mandatory.
return "handled"
}
if (
command === "backspace" &&
mandatory &&
anchorOffset === focusOffset &&
anchorOffset === 0
currentMandatory &&
isCollapsedSelection &&
startOffset === 0
) {
// Prevent backspace when at the beginning of the block, to avoid
// merge with the previous block
// Prevent backspace when at the beginning of a mandatory block,
// we don't want to merge mandatory blocks into previous blocks,
// they would lose their data property.
// Note: for the first content block, a backspace in this context
// doesn't use handleKeyCommand, but that's not a problem, it becomes an
// unstyled mandatory element
return "handled"
}
if (
command === "delete" &&
mandatory &&
anchorOffset === focusOffset &&
anchorOffset === blockLength
(currentMandatory || nextIsMandatory) &&
isCollapsedSelection &&
startOffset === currentBlockLength
) {
// Prevent delete when at the end of the block, to avoid
// merge with the next block
if (nextIsMandatory && !currentMandatory && !currentBlockLength) {
// When the current block is empty and the next one is mandatory,
// a delete of the current block would result in the next block being
// merged into the current block and therefore losing it's style and
// data (also the property of being mandatory).
// By doing a backwards delete instead we make sure the empty block is
// merged into the previous block and this doesn't affect the rest of
// the blocks.
// TODO: Would be nicer to use RichUtils.handleKeyCommand(editorState, 'backspace')
// but it returns undefined (see https://github.com/facebook/draft-js/issues/1849)
const blockSelection = new SelectionState({
anchorOffset: previousBlock.end,
anchorKey: previousBlock.key,
focusOffset: currentBlock.start,
focusKey: currentBlock.key,
isBackward: false,
hasFocus: selectionState.hasFocus
})
const nextContentState = Modifier.removeRange(
contentState,
blockSelection,
"backward"
)
setEditorState(
EditorState.push(
editorState,
nextContentState,
"delete-empty-block"
)
)
return "handled"
}
// Prevent delete when at the end of a block, when the current or the
// next block are mandatory; we don't want to merge mandatory blocks
// TODO: maybe better when the current is not mandatory and the next is mandatory
return "handled"
}
if (
placeholder &&
mandatory &&
((blockLength === 1 && command === "delete" && anchorOffset === 0) ||
(blockLength === 1 &&
command === "backspace" &&
anchorOffset === 1) ||
(blockLength === endOffset - startOffset &&
["backspace", "delete"].includes(command)))
currentMandatory &&
((currentBlockLength === 1 &&
isCollapsedSelection &&
((command === "delete" && startOffset === 0) ||
(command === "backspace" && endOffset === 1))) ||
currentBlockLength === endOffset - startOffset)
) {
// When a placeholder is given, instead of deleting last left character or
// instead of deleting the whole text content, replace it with placeholder
// When a placeholder is given, instead of deleting the last character
// or the whole text content of the block, replace it with placeholder
const nextState = editorState
setEditorState(
replaceWithPlaceholder(nextState, placeholder, 0, blockLength)
replaceWithPlaceholder(nextState, placeholder, 0, currentBlockLength)
)
return "handled"
}
}

return "not-handled"
}
return {
blockStyleFn: blockStyleFn,
handleReturn: handleReturn,
handleKeyCommand: handleKeyCommand
}
}
Expand Down

0 comments on commit f246884

Please sign in to comment.