mirror of
https://github.com/strapi/strapi.git
synced 2025-09-10 09:08:18 +00:00
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:
parent
395a47681f
commit
d739cd3dce
@ -652,6 +652,7 @@
|
|||||||
"components.Blocks.blocks.image": "Image",
|
"components.Blocks.blocks.image": "Image",
|
||||||
"components.Blocks.blocks.unorderedList": "Bulleted list",
|
"components.Blocks.blocks.unorderedList": "Bulleted list",
|
||||||
"components.Blocks.blocks.orderedList": "Numbered 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.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.Blocks.dnd.reorder": "{item}, moved. New position in the editor: {position}.",
|
||||||
"components.Wysiwyg.ToggleMode.markdown-mode": "Markdown mode",
|
"components.Wysiwyg.ToggleMode.markdown-mode": "Markdown mode",
|
||||||
|
@ -1,9 +1,14 @@
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
|
|
||||||
|
import { Box, SingleSelect, SingleSelectOption } from '@strapi/design-system';
|
||||||
import { Code } from '@strapi/icons';
|
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 { 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 { baseHandleConvert } from '../utils/conversions';
|
||||||
import { pressEnterTwiceToExit } from '../utils/enterKey';
|
import { pressEnterTwiceToExit } from '../utils/enterKey';
|
||||||
import { type Block } from '../utils/types';
|
import { type Block } from '../utils/types';
|
||||||
@ -15,6 +20,7 @@ const CodeBlock = styled.pre`
|
|||||||
overflow: auto;
|
overflow: auto;
|
||||||
padding: ${({ theme }) => `${theme.spaces[3]} ${theme.spaces[4]}`};
|
padding: ${({ theme }) => `${theme.spaces[3]} ${theme.spaces[4]}`};
|
||||||
flex-shrink: 1;
|
flex-shrink: 1;
|
||||||
|
|
||||||
& > code {
|
& > code {
|
||||||
font-family: 'SF Mono', SFMono-Regular, ui-monospace, 'DejaVu Sans Mono', Menlo, Consolas,
|
font-family: 'SF Mono', SFMono-Regular, ui-monospace, 'DejaVu Sans Mono', Menlo, Consolas,
|
||||||
monospace;
|
monospace;
|
||||||
@ -24,13 +30,71 @@ const CodeBlock = styled.pre`
|
|||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const codeBlocks: Pick<BlocksStore, 'code'> = {
|
const CodeEditor = (props: RenderElementProps) => {
|
||||||
code: {
|
const { editor } = useBlocksEditorContext('ImageDialog');
|
||||||
renderElement: (props) => (
|
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}>
|
<CodeBlock {...props.attributes}>
|
||||||
<code>{props.children}</code>
|
<code>{props.children}</code>
|
||||||
</CodeBlock>
|
</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,
|
icon: Code,
|
||||||
label: {
|
label: {
|
||||||
id: 'components.Blocks.blocks.code',
|
id: 'components.Blocks.blocks.code',
|
||||||
@ -39,7 +103,7 @@ const codeBlocks: Pick<BlocksStore, 'code'> = {
|
|||||||
matchNode: (node) => node.type === 'code',
|
matchNode: (node) => node.type === 'code',
|
||||||
isInBlocksSelector: true,
|
isInBlocksSelector: true,
|
||||||
handleConvert(editor) {
|
handleConvert(editor) {
|
||||||
baseHandleConvert<Block<'code'>>(editor, { type: 'code' });
|
baseHandleConvert<Block<'code'>>(editor, { type: 'code', language: 'plaintext' });
|
||||||
},
|
},
|
||||||
handleEnterKey(editor) {
|
handleEnterKey(editor) {
|
||||||
pressEnterTwiceToExit(editor);
|
pressEnterTwiceToExit(editor);
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
/* eslint-disable testing-library/no-node-access */
|
/* 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 { createEditor, Transforms, Editor } from 'slate';
|
||||||
|
|
||||||
import { codeBlocks } from '../Code';
|
import { codeBlocks } from '../Code';
|
||||||
@ -21,7 +22,9 @@ describe('Code', () => {
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
|
renderOptions: {
|
||||||
wrapper: Wrapper,
|
wrapper: Wrapper,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -118,6 +121,7 @@ describe('Code', () => {
|
|||||||
expect(baseEditor.children).toEqual([
|
expect(baseEditor.children).toEqual([
|
||||||
{
|
{
|
||||||
type: 'code',
|
type: 'code',
|
||||||
|
language: 'plaintext',
|
||||||
children: [
|
children: [
|
||||||
{
|
{
|
||||||
type: 'text',
|
type: 'text',
|
||||||
|
@ -607,6 +607,7 @@ describe('BlocksToolbar', () => {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: 'code',
|
type: 'code',
|
||||||
|
language: 'plaintext',
|
||||||
children: [
|
children: [
|
||||||
{
|
{
|
||||||
type: 'text',
|
type: 'text',
|
||||||
|
@ -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',
|
||||||
|
},
|
||||||
|
];
|
@ -1,5 +1,3 @@
|
|||||||
import * as React from 'react';
|
|
||||||
|
|
||||||
import { type Element, type Path, Editor, Transforms } from 'slate';
|
import { type Element, type Path, Editor, Transforms } from 'slate';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -77,6 +77,7 @@ interface QuoteBlockNode extends BaseNode {
|
|||||||
|
|
||||||
interface CodeBlockNode extends BaseNode {
|
interface CodeBlockNode extends BaseNode {
|
||||||
type: 'code';
|
type: 'code';
|
||||||
|
language?: string;
|
||||||
children: DefaultInlineNode[];
|
children: DefaultInlineNode[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
53
tests/e2e/tests/content-manager/blocks.spec.ts
Normal file
53
tests/e2e/tests/content-manager/blocks.spec.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
Loading…
x
Reference in New Issue
Block a user