From adbd8208840fbf154c5d433baaeb762e162bc2eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9mi=20de=20Juvigny?= <8087692+remidej@users.noreply.github.com> Date: Wed, 29 Nov 2023 11:19:25 +0100 Subject: [PATCH] feat(blocks): make it easier to exit code blocks (#18894) * handle double enter on code blocks * rename code translations * allow forced line breaks * mark feedback * don't add paragraph when converting to code block --- .../components/BlocksInput/Blocks/Code.tsx | 13 +-- .../components/BlocksInput/Blocks/Quote.tsx | 41 +------ .../BlocksInput/Blocks/tests/Code.test.tsx | 100 ++++++------------ .../BlocksInput/Blocks/tests/Quote.test.tsx | 2 +- .../components/BlocksInput/BlocksContent.tsx | 11 +- .../components/BlocksInput/Modifiers.tsx | 2 +- .../BlocksInput/tests/BlocksToolbar.test.tsx | 2 +- .../components/BlocksInput/utils/enterKey.ts | 49 +++++++++ .../core/admin/admin/src/translations/en.json | 3 +- 9 files changed, 104 insertions(+), 119 deletions(-) create mode 100644 packages/core/admin/admin/src/content-manager/components/BlocksInput/utils/enterKey.ts diff --git a/packages/core/admin/admin/src/content-manager/components/BlocksInput/Blocks/Code.tsx b/packages/core/admin/admin/src/content-manager/components/BlocksInput/Blocks/Code.tsx index 008e97fce1..2f0166cdf2 100644 --- a/packages/core/admin/admin/src/content-manager/components/BlocksInput/Blocks/Code.tsx +++ b/packages/core/admin/admin/src/content-manager/components/BlocksInput/Blocks/Code.tsx @@ -1,11 +1,11 @@ import * as React from 'react'; import { Code } from '@strapi/icons'; -import { Transforms } from 'slate'; import styled from 'styled-components'; import { type BlocksStore } from '../BlocksEditor'; -import { insertEmptyBlockAtLast, isLastBlockType, baseHandleConvert } from '../utils/conversions'; +import { baseHandleConvert } from '../utils/conversions'; +import { pressEnterTwiceToExit } from '../utils/enterKey'; import { type Block } from '../utils/types'; const CodeBlock = styled.pre.attrs({ role: 'code' })` @@ -34,20 +34,15 @@ const codeBlocks: Pick = { icon: Code, label: { id: 'components.Blocks.blocks.code', - defaultMessage: 'Code', + defaultMessage: 'Code block', }, matchNode: (node) => node.type === 'code', isInBlocksSelector: true, handleConvert(editor) { baseHandleConvert>(editor, { type: 'code' }); - - if (isLastBlockType(editor, 'code')) { - insertEmptyBlockAtLast(editor); - } }, handleEnterKey(editor) { - // Insert a new line within the block - Transforms.insertText(editor, '\n'); + pressEnterTwiceToExit(editor); }, }, }; diff --git a/packages/core/admin/admin/src/content-manager/components/BlocksInput/Blocks/Quote.tsx b/packages/core/admin/admin/src/content-manager/components/BlocksInput/Blocks/Quote.tsx index 4e16517da7..140ab5d16e 100644 --- a/packages/core/admin/admin/src/content-manager/components/BlocksInput/Blocks/Quote.tsx +++ b/packages/core/admin/admin/src/content-manager/components/BlocksInput/Blocks/Quote.tsx @@ -1,11 +1,11 @@ import * as React from 'react'; import { Quote } from '@strapi/icons'; -import { type Text, Editor, Node, Transforms } from 'slate'; import styled from 'styled-components'; import { type BlocksStore } from '../BlocksEditor'; import { baseHandleConvert } from '../utils/conversions'; +import { pressEnterTwiceToExit } from '../utils/enterKey'; import { type Block } from '../utils/types'; const Blockquote = styled.blockquote.attrs({ role: 'blockquote' })` @@ -16,10 +16,6 @@ const Blockquote = styled.blockquote.attrs({ role: 'blockquote' })` color: ${({ theme }) => theme.colors.neutral600}; `; -const isText = (node: unknown): node is Text => { - return Node.isNode(node) && !Editor.isEditor(node) && node.type === 'text'; -}; - const quoteBlocks: Pick = { quote: { renderElement: (props) =>
{props.children}
, @@ -34,40 +30,7 @@ const quoteBlocks: Pick = { baseHandleConvert>(editor, { type: 'quote' }); }, handleEnterKey(editor) { - /** - * To determine if we should break out of the quote node, check 2 things: - * 1. If the cursor is at the end of the quote node - * 2. If the last line of the quote node is empty - */ - const quoteNodeEntry = Editor.above(editor, { - match: (node) => !Editor.isEditor(node) && node.type === 'quote', - }); - if (!quoteNodeEntry || !editor.selection) { - return; - } - const [quoteNode, quoteNodePath] = quoteNodeEntry; - const isNodeEnd = Editor.isEnd(editor, editor.selection.anchor, quoteNodePath); - const lastTextNode = quoteNode.children.at(-1); - const isEmptyLine = isText(lastTextNode) && lastTextNode.text.endsWith('\n'); - - if (isNodeEnd && isEmptyLine) { - // Remove the last line break - Transforms.delete(editor, { distance: 1, unit: 'character', reverse: true }); - // Break out of the quote node new paragraph - Transforms.insertNodes(editor, { - type: 'paragraph', - children: [{ type: 'text', text: '' }], - }); - } else { - // Otherwise insert a new line within the quote node - Transforms.insertText(editor, '\n'); - - // If there's nothing after the cursor, disable modifiers - if (isNodeEnd) { - Editor.removeMark(editor, 'bold'); - Editor.removeMark(editor, 'italic'); - } - } + pressEnterTwiceToExit(editor); }, }, }; diff --git a/packages/core/admin/admin/src/content-manager/components/BlocksInput/Blocks/tests/Code.test.tsx b/packages/core/admin/admin/src/content-manager/components/BlocksInput/Blocks/tests/Code.test.tsx index a03f86782b..38eec6ca7b 100644 --- a/packages/core/admin/admin/src/content-manager/components/BlocksInput/Blocks/tests/Code.test.tsx +++ b/packages/core/admin/admin/src/content-manager/components/BlocksInput/Blocks/tests/Code.test.tsx @@ -32,37 +32,65 @@ describe('Code', () => { expect(code).toBeInTheDocument(); }); - it('handles enter key on code block', () => { + it('handles enter key on a code block', () => { const baseEditor = createEditor(); + baseEditor.children = [ { type: 'code', children: [ { type: 'text', - text: 'Line of code', + text: 'Some code', }, ], }, ]; - // Set the cursor at the end of the fast list item + // Simulate enter key press at the end of the code Transforms.select(baseEditor, { anchor: Editor.end(baseEditor, []), focus: Editor.end(baseEditor, []), }); - - // Simulate the enter key codeBlocks.code.handleEnterKey!(baseEditor); - // Should insert a newline within the code block (shoudn't exit the code block) + // Should enter a line break within the code expect(baseEditor.children).toEqual([ { type: 'code', children: [ { type: 'text', - text: 'Line of code\n', + text: 'Some code\n', + }, + ], + }, + ]); + + // Simulate enter key press at the end of the code again + Transforms.select(baseEditor, { + anchor: Editor.end(baseEditor, []), + focus: Editor.end(baseEditor, []), + }); + codeBlocks.code.handleEnterKey!(baseEditor); + + // Should delete the line break and create a paragraph after the code + expect(baseEditor.children).toEqual([ + { + type: 'code', + children: [ + { + type: 'text', + text: 'Some code', + }, + ], + }, + { + type: 'paragraph', + children: [ + { + type: 'text', + text: '', }, ], }, @@ -100,64 +128,6 @@ describe('Code', () => { }, ], }, - // Should insert a new paragraph as it was the last block - { - type: 'paragraph', - children: [{ type: 'text', text: '' }], - }, - ]); - }); - - it('should not insert an empty block below if the converted block is not the last one', () => { - const baseEditor = createEditor(); - baseEditor.children = [ - { - type: 'quote', - children: [ - { - type: 'text', - text: 'Some quote', - }, - ], - }, - { - type: 'paragraph', - children: [ - { - type: 'text', - text: 'Some paragraph', - }, - ], - }, - ]; - - Transforms.select(baseEditor, { - anchor: { path: [0, 0], offset: 0 }, - focus: { path: [0, 0], offset: 0 }, - }); - - codeBlocks.code.handleConvert!(baseEditor); - - expect(baseEditor.children).toEqual([ - { - type: 'code', - children: [ - { - type: 'text', - text: 'Some quote', - }, - ], - }, - { - type: 'paragraph', - children: [ - { - type: 'text', - text: 'Some paragraph', - }, - ], - }, - // Nothing should be inserted here ]); }); }); diff --git a/packages/core/admin/admin/src/content-manager/components/BlocksInput/Blocks/tests/Quote.test.tsx b/packages/core/admin/admin/src/content-manager/components/BlocksInput/Blocks/tests/Quote.test.tsx index de7de438db..2a9bd41e07 100644 --- a/packages/core/admin/admin/src/content-manager/components/BlocksInput/Blocks/tests/Quote.test.tsx +++ b/packages/core/admin/admin/src/content-manager/components/BlocksInput/Blocks/tests/Quote.test.tsx @@ -95,7 +95,7 @@ describe('Quote', () => { ]); }); - it('has no modofiers applied when pressing enter key at the end of a quote', () => { + it('has no modifiers applied when pressing enter key at the end of a quote', () => { const baseEditor = createEditor(); baseEditor.children = [ { diff --git a/packages/core/admin/admin/src/content-manager/components/BlocksInput/BlocksContent.tsx b/packages/core/admin/admin/src/content-manager/components/BlocksInput/BlocksContent.tsx index 2767b467c7..6bfc5da9fc 100644 --- a/packages/core/admin/admin/src/content-manager/components/BlocksInput/BlocksContent.tsx +++ b/packages/core/admin/admin/src/content-manager/components/BlocksInput/BlocksContent.tsx @@ -1,6 +1,7 @@ import * as React from 'react'; import { Box } from '@strapi/design-system'; +import { Transforms } from 'slate'; import { type ReactEditor, type RenderElementProps, @@ -68,7 +69,7 @@ const BlocksContent = ({ placeholder }: BlocksInputProps) => { [blocks] ); - const handleEnter = () => { + const handleEnter = (event: React.KeyboardEvent) => { if (!editor.selection) { return; } @@ -82,6 +83,12 @@ const BlocksContent = ({ placeholder }: BlocksInputProps) => { return; } + // Allow forced line breaks when shift is pressed + if (event.shiftKey && selectedNode.type !== 'image') { + Transforms.insertText(editor, '\n'); + return; + } + // Check if there's an enter handler for the selected block if (selectedBlock.handleEnterKey) { selectedBlock.handleEnterKey(editor); @@ -126,7 +133,7 @@ const BlocksContent = ({ placeholder }: BlocksInputProps) => { const handleKeyDown: React.KeyboardEventHandler = (event) => { if (event.key === 'Enter') { event.preventDefault(); - handleEnter(); + handleEnter(event); } if (event.key === 'Backspace') { handleBackspaceEvent(event); diff --git a/packages/core/admin/admin/src/content-manager/components/BlocksInput/Modifiers.tsx b/packages/core/admin/admin/src/content-manager/components/BlocksInput/Modifiers.tsx index 5655507c04..5bfbc1b416 100644 --- a/packages/core/admin/admin/src/content-manager/components/BlocksInput/Modifiers.tsx +++ b/packages/core/admin/admin/src/content-manager/components/BlocksInput/Modifiers.tsx @@ -117,7 +117,7 @@ const modifiers: ModifiersStore = { code: { icon: Code, isValidEventKey: (event) => event.key === 'e', - label: { id: 'components.Blocks.modifiers.code', defaultMessage: 'Code' }, + label: { id: 'components.Blocks.modifiers.code', defaultMessage: 'Inline code' }, checkIsActive: (editor) => baseCheckIsActive(editor, 'code'), handleToggle: (editor) => baseHandleToggle(editor, 'code'), renderLeaf: (children) => {children}, diff --git a/packages/core/admin/admin/src/content-manager/components/BlocksInput/tests/BlocksToolbar.test.tsx b/packages/core/admin/admin/src/content-manager/components/BlocksInput/tests/BlocksToolbar.test.tsx index 5e8b9df4e8..6c50c18ac3 100644 --- a/packages/core/admin/admin/src/content-manager/components/BlocksInput/tests/BlocksToolbar.test.tsx +++ b/packages/core/admin/admin/src/content-manager/components/BlocksInput/tests/BlocksToolbar.test.tsx @@ -465,7 +465,7 @@ describe('BlocksToolbar', () => { // Convert it to a code block const selectDropdown = screen.getByRole('combobox', { name: /Select a block/i }); await user.click(selectDropdown); - await user.click(screen.getByRole('option', { name: 'Code' })); + await user.click(screen.getByRole('option', { name: 'Code block' })); // The list should have been split in two expect(baseEditor.children).toEqual([ diff --git a/packages/core/admin/admin/src/content-manager/components/BlocksInput/utils/enterKey.ts b/packages/core/admin/admin/src/content-manager/components/BlocksInput/utils/enterKey.ts new file mode 100644 index 0000000000..c085f3df9f --- /dev/null +++ b/packages/core/admin/admin/src/content-manager/components/BlocksInput/utils/enterKey.ts @@ -0,0 +1,49 @@ +import { type Text, Editor, Node, Transforms } from 'slate'; + +const isText = (node: unknown): node is Text => { + return Node.isNode(node) && !Editor.isEditor(node) && node.type === 'text'; +}; + +/** + * Inserts a line break the first time the user presses enter, and exits the node the second time. + */ +const pressEnterTwiceToExit = (editor: Editor) => { + /** + * To determine if we should break out of the node, check 2 things: + * 1. If the cursor is at the end of the node + * 2. If the last line of the node is empty + */ + const nodeEntry = Editor.above(editor, { + match: (node) => !Editor.isEditor(node) && !['link', 'text'].includes(node.type), + }); + if (!nodeEntry || !editor.selection) { + return; + } + const [node, nodePath] = nodeEntry; + const isNodeEnd = Editor.isEnd(editor, editor.selection.anchor, nodePath); + const lastTextNode = node.children.at(-1); + const isEmptyLine = isText(lastTextNode) && lastTextNode.text.endsWith('\n'); + + if (isNodeEnd && isEmptyLine) { + // Remove the last line break + Transforms.delete(editor, { distance: 1, unit: 'character', reverse: true }); + // Break out of the node by creating a new paragraph + Transforms.insertNodes(editor, { + type: 'paragraph', + children: [{ type: 'text', text: '' }], + }); + return; + } + + // Otherwise insert a new line within the node + Transforms.insertText(editor, '\n'); + + // If there's nothing after the cursor, disable modifiers + if (isNodeEnd) { + ['bold', 'italic', 'underline', 'strikethrough', 'code'].forEach((modifier) => { + Editor.removeMark(editor, modifier); + }); + } +}; + +export { pressEnterTwiceToExit }; diff --git a/packages/core/admin/admin/src/translations/en.json b/packages/core/admin/admin/src/translations/en.json index 93ce5bf883..2b7fe190db 100644 --- a/packages/core/admin/admin/src/translations/en.json +++ b/packages/core/admin/admin/src/translations/en.json @@ -629,7 +629,7 @@ "components.Blocks.modifiers.italic": "Italic", "components.Blocks.modifiers.underline": "Underline", "components.Blocks.modifiers.strikethrough": "Strikethrough", - "components.Blocks.modifiers.code": "Code", + "components.Blocks.modifiers.code": "Inline code", "components.Blocks.link": "Link", "components.Blocks.popover.text": "Text", "components.Blocks.popover.text.placeholder": "Enter link text", @@ -646,6 +646,7 @@ "components.Blocks.blocks.heading4": "Heading 4", "components.Blocks.blocks.heading5": "Heading 5", "components.Blocks.blocks.heading6": "Heading 6", + "components.Blocks.blocks.code": "Code block", "components.Blocks.blocks.quote": "Quote", "components.Blocks.blocks.image": "Image", "components.Blocks.blocks.unorderedList": "Bulleted list",