mirror of
https://github.com/strapi/strapi.git
synced 2025-09-07 15:49:24 +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.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",
|
||||
|
@ -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);
|
||||
|
@ -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',
|
||||
|
@ -607,6 +607,7 @@ describe('BlocksToolbar', () => {
|
||||
},
|
||||
{
|
||||
type: 'code',
|
||||
language: 'plaintext',
|
||||
children: [
|
||||
{
|
||||
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';
|
||||
|
||||
/**
|
||||
|
@ -77,6 +77,7 @@ interface QuoteBlockNode extends BaseNode {
|
||||
|
||||
interface CodeBlockNode extends BaseNode {
|
||||
type: 'code';
|
||||
language?: string;
|
||||
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