feat: add language selector to the blocks code editor (#20469)

* feat: display code language selector in blocks

* chore: rename syntax to language

* chore: use slate for state

* chore: add blocks e2e test

* fix: unit test

* chore: move language list to constants file

* fix: unused import
This commit is contained in:
Rémi de Juvigny 2024-06-11 12:05:01 +02:00 committed by GitHub
parent 395a47681f
commit d739cd3dce
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 346 additions and 10 deletions

View File

@ -652,6 +652,7 @@
"components.Blocks.blocks.image": "Image",
"components.Blocks.blocks.unorderedList": "Bulleted list",
"components.Blocks.blocks.orderedList": "Numbered list",
"components.Blocks.blocks.code.languageLabel": "Select a language",
"components.Blocks.dnd.instruction": "To reorder blocks, press Command or Control along with Shift and the Up or Down arrow keys",
"components.Blocks.dnd.reorder": "{item}, moved. New position in the editor: {position}.",
"components.Wysiwyg.ToggleMode.markdown-mode": "Markdown mode",

View File

@ -1,9 +1,14 @@
import * as React from 'react';
import { Box, SingleSelect, SingleSelectOption } from '@strapi/design-system';
import { Code } from '@strapi/icons';
import { useIntl } from 'react-intl';
import { Editor, Transforms } from 'slate';
import { useSelected, type RenderElementProps, useFocused, ReactEditor } from 'slate-react';
import { styled } from 'styled-components';
import { type BlocksStore } from '../BlocksEditor';
import { useBlocksEditorContext, type BlocksStore } from '../BlocksEditor';
import { codeLanguages } from '../utils/constants';
import { baseHandleConvert } from '../utils/conversions';
import { pressEnterTwiceToExit } from '../utils/enterKey';
import { type Block } from '../utils/types';
@ -15,6 +20,7 @@ const CodeBlock = styled.pre`
overflow: auto;
padding: ${({ theme }) => `${theme.spaces[3]} ${theme.spaces[4]}`};
flex-shrink: 1;
& > code {
font-family: 'SF Mono', SFMono-Regular, ui-monospace, 'DejaVu Sans Mono', Menlo, Consolas,
monospace;
@ -24,13 +30,71 @@ const CodeBlock = styled.pre`
}
`;
const codeBlocks: Pick<BlocksStore, 'code'> = {
code: {
renderElement: (props) => (
const CodeEditor = (props: RenderElementProps) => {
const { editor } = useBlocksEditorContext('ImageDialog');
const editorIsFocused = useFocused();
const imageIsSelected = useSelected();
const { formatMessage } = useIntl();
const [isSelectOpen, setIsSelectOpen] = React.useState(false);
const shouldDisplayLanguageSelect = (editorIsFocused && imageIsSelected) || isSelectOpen;
return (
<Box position="relative" width="100%">
<CodeBlock {...props.attributes}>
<code>{props.children}</code>
</CodeBlock>
),
{shouldDisplayLanguageSelect && (
<Box
position="absolute"
background="neutral0"
borderColor="neutral150"
borderStyle="solid"
borderWidth="0.5px"
shadow="tableShadow"
top="100%"
marginTop={1}
right={0}
padding={1}
hasRadius
>
<SingleSelect
onChange={(open) => {
Transforms.setNodes(
editor,
{ language: open.toString() },
{ match: (node) => !Editor.isEditor(node) && node.type === 'code' }
);
}}
value={(props.element.type === 'code' && props.element.language) || 'plaintext'}
onOpenChange={(open) => {
setIsSelectOpen(open);
// Focus the editor again when closing the select so the user can continue typing
if (!open) {
ReactEditor.focus(editor);
}
}}
onCloseAutoFocus={(e) => e.preventDefault()}
aria-label={formatMessage({
id: 'components.Blocks.blocks.code.languageLabel',
defaultMessage: 'Select a language',
})}
>
{codeLanguages.map(({ value, label }) => (
<SingleSelectOption value={value} key={value}>
{label}
</SingleSelectOption>
))}
</SingleSelect>
</Box>
)}
</Box>
);
};
const codeBlocks: Pick<BlocksStore, 'code'> = {
code: {
renderElement: (props) => <CodeEditor {...props} />,
icon: Code,
label: {
id: 'components.Blocks.blocks.code',
@ -39,7 +103,7 @@ const codeBlocks: Pick<BlocksStore, 'code'> = {
matchNode: (node) => node.type === 'code',
isInBlocksSelector: true,
handleConvert(editor) {
baseHandleConvert<Block<'code'>>(editor, { type: 'code' });
baseHandleConvert<Block<'code'>>(editor, { type: 'code', language: 'plaintext' });
},
handleEnterKey(editor) {
pressEnterTwiceToExit(editor);

View File

@ -1,5 +1,6 @@
/* eslint-disable testing-library/no-node-access */
import { render, screen } from '@testing-library/react';
import { render, screen } from '@tests/utils';
import { createEditor, Transforms, Editor } from 'slate';
import { codeBlocks } from '../Code';
@ -21,7 +22,9 @@ describe('Code', () => {
},
}),
{
wrapper: Wrapper,
renderOptions: {
wrapper: Wrapper,
},
}
);
@ -118,6 +121,7 @@ describe('Code', () => {
expect(baseEditor.children).toEqual([
{
type: 'code',
language: 'plaintext',
children: [
{
type: 'text',

View File

@ -607,6 +607,7 @@ describe('BlocksToolbar', () => {
},
{
type: 'code',
language: 'plaintext',
children: [
{
type: 'text',

View File

@ -0,0 +1,214 @@
export const codeLanguages: { value: string; label: string }[] = [
{
value: 'asm',
label: 'Assembly',
},
{
value: 'bash',
label: 'Bash',
},
{
value: 'c',
label: 'C',
},
{
value: 'clojure',
label: 'Clojure',
},
{
value: 'cobol',
label: 'COBOL',
},
{
value: 'cpp',
label: 'C++',
},
{
value: 'csharp',
label: 'C#',
},
{
value: 'css',
label: 'CSS',
},
{
value: 'dart',
label: 'Dart',
},
{
value: 'dockerfile',
label: 'Dockerfile',
},
{
value: 'elixir',
label: 'Elixir',
},
{
value: 'erlang',
label: 'Erlang',
},
{
value: 'fortran',
label: 'Fortran',
},
{
value: 'fsharp',
label: 'F#',
},
{
value: 'go',
label: 'Go',
},
{
value: 'graphql',
label: 'GraphQL',
},
{
value: 'groovy',
label: 'Groovy',
},
{
value: 'haskell',
label: 'Haskell',
},
{
value: 'haxe',
label: 'Haxe',
},
{
value: 'html',
label: 'HTML',
},
{
value: 'ini',
label: 'INI',
},
{
value: 'java',
label: 'Java',
},
{
value: 'javascript',
label: 'JavaScript',
},
{
value: 'jsx',
label: 'JavaScript (React)',
},
{
value: 'json',
label: 'JSON',
},
{
value: 'julia',
label: 'Julia',
},
{
value: 'kotlin',
label: 'Kotlin',
},
{
value: 'latex',
label: 'LaTeX',
},
{
value: 'lua',
label: 'Lua',
},
{
value: 'markdown',
label: 'Markdown',
},
{
value: 'matlab',
label: 'MATLAB',
},
{
value: 'makefile',
label: 'Makefile',
},
{
value: 'objectivec',
label: 'Objective-C',
},
{
value: 'perl',
label: 'Perl',
},
{
value: 'php',
label: 'PHP',
},
{
value: 'plaintext',
label: 'Plain text',
},
{
value: 'powershell',
label: 'PowerShell',
},
{
value: 'python',
label: 'Python',
},
{
value: 'r',
label: 'R',
},
{
value: 'ruby',
label: 'Ruby',
},
{
value: 'rust',
label: 'Rust',
},
{
value: 'sas',
label: 'SAS',
},
{
value: 'scala',
label: 'Scala',
},
{
value: 'scheme',
label: 'Scheme',
},
{
value: 'shell',
label: 'Shell',
},
{
value: 'sql',
label: 'SQL',
},
{
value: 'stata',
label: 'Stata',
},
{
value: 'swift',
label: 'Swift',
},
{
value: 'typescript',
label: 'TypeScript',
},
{
value: 'tsx',
label: 'TypeScript (React)',
},
{
value: 'vbnet',
label: 'VB.NET',
},
{
value: 'xml',
label: 'XML',
},
{
value: 'yaml',
label: 'YAML',
},
];

View File

@ -1,5 +1,3 @@
import * as React from 'react';
import { type Element, type Path, Editor, Transforms } from 'slate';
/**

View File

@ -77,6 +77,7 @@ interface QuoteBlockNode extends BaseNode {
interface CodeBlockNode extends BaseNode {
type: 'code';
language?: string;
children: DefaultInlineNode[];
}

View File

@ -0,0 +1,53 @@
import { test, expect } from '@playwright/test';
import { login } from '../../utils/login';
import { resetDatabaseAndImportDataFromPath } from '../../utils/dts-import';
import { findAndClose } from '../../utils/shared';
const EDIT_URL = /\/admin\/content-manager\/single-types\/api::homepage.homepage(\?.*)?/;
test.describe('Blocks editor', () => {
test.beforeEach(async ({ page }) => {
await resetDatabaseAndImportDataFromPath('with-admin.tar');
await page.goto('/admin');
await login({ page });
});
test('adds a code block and specifies the language', async ({ page }) => {
// Write some text into a blocks editor
const code = 'const problems = 99';
await page.goto('/admin/content-manager/single-types/api::homepage.homepage');
await expect(page.getByRole('link', { name: 'Back' })).toBeVisible();
const textbox = page.getByRole('textbox').nth(1);
await expect(textbox).toBeVisible();
await textbox.click();
await textbox.fill(code);
await expect(page.getByText(code)).toBeVisible();
// Use the toolbar to convert the block to a code block and specify the language
const toolbar = page.getByRole('toolbar');
await toolbar.getByRole('combobox').click();
await page.getByLabel('Code block').click();
await textbox.getByText(code).click();
const languageSelect = page.getByText('Plain text');
await expect(languageSelect).toBeVisible();
await languageSelect.click();
await page.getByText('Fortran').click();
await expect(page.getByText('Fortran')).toBeVisible();
// Ensure we're not in the middle of the code block, so that double enter creates a new block
for (let i = 0; i < code.length; i++) {
await page.keyboard.press('ArrowRight');
}
await page.keyboard.press('Enter');
await page.keyboard.press('Enter');
await expect(page.getByText('Fortran')).not.toBeVisible();
// Save and reload to make sure the change is persisted
await page.getByRole('button', { name: 'Save' }).click();
await page.reload();
await expect(page.getByText(code)).toBeVisible();
await page.getByText(code).click();
await expect(page.getByText('Fortran')).toBeVisible();
await expect(page.getByText('Plain text')).not.toBeVisible();
});
});