From e8754a69cca8f0ce42cd81b6e6393862d2bac890 Mon Sep 17 00:00:00 2001 From: Ahtesham Quraish Date: Wed, 17 Sep 2025 12:12:48 +0500 Subject: [PATCH] feat: Add formatting buttons to new Markdown editor #2073 --- .../CodeEditor/EditorToolbar.scss | 53 +++++++ .../CodeEditor/EditorToolbar.test.jsx | 136 ++++++++++++++++++ .../CodeEditor/EditorToolbar.tsx | 88 ++++++++++++ .../sharedComponents/CodeEditor/constants.js | 8 ++ .../sharedComponents/CodeEditor/hooks.js | 2 + .../sharedComponents/CodeEditor/index.jsx | 10 +- .../sharedComponents/CodeEditor/messages.js | 35 +++++ 7 files changed, 330 insertions(+), 2 deletions(-) create mode 100644 src/editors/sharedComponents/CodeEditor/EditorToolbar.scss create mode 100644 src/editors/sharedComponents/CodeEditor/EditorToolbar.test.jsx create mode 100644 src/editors/sharedComponents/CodeEditor/EditorToolbar.tsx diff --git a/src/editors/sharedComponents/CodeEditor/EditorToolbar.scss b/src/editors/sharedComponents/CodeEditor/EditorToolbar.scss new file mode 100644 index 0000000000..2aa41af5d7 --- /dev/null +++ b/src/editors/sharedComponents/CodeEditor/EditorToolbar.scss @@ -0,0 +1,53 @@ +.editor-toolbar { + display: flex; + align-items: center; + gap: 4px; + padding: 6px; + background: #D1DAE5; + border: 1px solid #D0D5DD; + border-bottom: none; + border-radius: 4px 4px 0 0; + + .toolbar-button { + display: flex; + align-items: center; + gap: 4px; + background: transparent; + border: none; + border-radius: 3px; + padding: 6px 5px; + font-size: 11px; + font-weight: 500; + color: #222222; + cursor: pointer; + transition: background .2s; + + svg { + font-size: 14px; + } + + &:hover { + background: #202021; + } + + &:active { + background: #CBD5E1; + } + } + + .toolbar-separator { + width: 1px; + height: 20px; + background: #CCCCCC; + margin: 0 4px; + } + + .editor-icon { + font-weight: bold; + font-size: 12px; + } + + .editor-btn-dropdown { + gap: 0; + } +} diff --git a/src/editors/sharedComponents/CodeEditor/EditorToolbar.test.jsx b/src/editors/sharedComponents/CodeEditor/EditorToolbar.test.jsx new file mode 100644 index 0000000000..4871594796 --- /dev/null +++ b/src/editors/sharedComponents/CodeEditor/EditorToolbar.test.jsx @@ -0,0 +1,136 @@ +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { IntlProvider } from '@edx/frontend-platform/i18n'; +import EditorToolbar from './EditorToolbar'; +import messages from './messages'; + +describe('', () => { + let mockDispatch; + let mockFocus; + let mockEditor; + + const renderWithIntl = (ui) => render( + [v.id, v.defaultMessage]), + )} + > + {ui} + , + ); + + beforeEach(() => { + mockDispatch = jest.fn(); + mockFocus = jest.fn(); + mockEditor = { + state: { + selection: { main: { from: 0, to: 0 } }, + }, + dispatch: mockDispatch, + focus: mockFocus, + }; + }); + + it('renders all toolbar buttons', () => { + renderWithIntl(); + + expect(screen.getByRole('button', { name: /Heading/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /Multiple Choice/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /Checkboxes/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /Text Input/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /Numerical Input/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /Dropdown/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /Explanation/i })).toBeInTheDocument(); + }); + + it('inserts heading text when heading button is clicked', () => { + renderWithIntl(); + fireEvent.click(screen.getByRole('button', { name: /Heading/i })); + + expect(mockDispatch).toHaveBeenCalledWith( + expect.objectContaining({ + changes: expect.objectContaining({ insert: '## Heading\n\n' }), + }), + ); + expect(mockFocus).toHaveBeenCalled(); + }); + + it('inserts multiple choice when multiple choice button is clicked', () => { + renderWithIntl(); + fireEvent.click(screen.getByRole('button', { name: /Multiple Choice/i })); + + expect(mockDispatch).toHaveBeenCalledWith( + expect.objectContaining({ + changes: expect.objectContaining({ + insert: expect.stringContaining('( ) Option 1'), + }), + }), + ); + }); + + it('inserts checkboxes when checkboxes button is clicked', () => { + renderWithIntl(); + fireEvent.click(screen.getByRole('button', { name: /Checkboxes/i })); + + expect(mockDispatch).toHaveBeenCalledWith( + expect.objectContaining({ + changes: expect.objectContaining({ + insert: expect.stringContaining('[ ] Incorrect'), + }), + }), + ); + }); + + it('inserts text input when text input button is clicked', () => { + renderWithIntl(); + fireEvent.click(screen.getByRole('button', { name: /Text Input/i })); + + expect(mockDispatch).toHaveBeenCalledWith( + expect.objectContaining({ + changes: expect.objectContaining({ + insert: expect.stringContaining('Type your answer here:'), + }), + }), + ); + }); + + it('inserts numerical input when numerical input button is clicked', () => { + renderWithIntl(); + fireEvent.click(screen.getByRole('button', { name: /Numerical Input/i })); + + expect(mockDispatch).toHaveBeenCalledWith( + expect.objectContaining({ + changes: expect.objectContaining({ + insert: expect.stringContaining('= 100'), + }), + }), + ); + }); + + it('inserts dropdown when dropdown button is clicked', () => { + renderWithIntl(); + fireEvent.click(screen.getByRole('button', { name: /Dropdown/i })); + + expect(mockDispatch).toHaveBeenCalledWith( + expect.objectContaining({ + changes: expect.objectContaining({ + insert: expect.stringContaining('[Dropdown:'), + }), + }), + ); + }); + + it('inserts explanation when explanation button is clicked', () => { + renderWithIntl(); + fireEvent.click(screen.getByRole('button', { name: /Explanation/i })); + + expect(mockDispatch).toHaveBeenCalledWith( + expect.objectContaining({ + changes: expect.objectContaining({ + insert: expect.stringContaining('>> Add explanation'), + }), + }), + ); + }); +}); diff --git a/src/editors/sharedComponents/CodeEditor/EditorToolbar.tsx b/src/editors/sharedComponents/CodeEditor/EditorToolbar.tsx new file mode 100644 index 0000000000..246d4301e8 --- /dev/null +++ b/src/editors/sharedComponents/CodeEditor/EditorToolbar.tsx @@ -0,0 +1,88 @@ +import React from 'react'; +import { Button, Icon } from '@openedx/paragon'; +import { EditorView } from '@codemirror/view'; +import { useIntl } from '@edx/frontend-platform/i18n'; +import { + CheckBoxIcon, Lightbulb, ArrowDropDown, ViewList, +} from '@openedx/paragon/icons'; + +import messages from './messages'; +import { + HEADING, + MULTIPLE_CHOICE, + CHECKBOXES, + TEXT_INPUT, + NUMERICAL_INPUT, + DROPDOWN, + EXPLANATION, +} from './constants'; + +import './EditorToolbar.scss'; + +// ✅ Define props type +interface EditorToolbarProps { + editorRef: EditorView | null; +} + +const EditorToolbar: React.FC = ({ editorRef }) => { + const intl = useIntl(); + + const insertAtCursor = (text: string) => { + if (!editorRef) { return; } + const { from, to } = editorRef.state.selection.main; + editorRef.dispatch({ + changes: { from, to, insert: text }, + selection: { anchor: from + text.length }, + }); + editorRef.focus(); + }; + + return ( +
+ + + + + + + + + + + + + + + + + +
+ ); +}; + +export default EditorToolbar; diff --git a/src/editors/sharedComponents/CodeEditor/constants.js b/src/editors/sharedComponents/CodeEditor/constants.js index bb390430c0..5ff0011969 100644 --- a/src/editors/sharedComponents/CodeEditor/constants.js +++ b/src/editors/sharedComponents/CodeEditor/constants.js @@ -34,3 +34,11 @@ const alphanumericMap = { quot: '"', }; export default alphanumericMap; + +export const HEADING = '## Heading\n\n'; +export const MULTIPLE_CHOICE = '( ) Option 1\n( ) Option 2\n(x) Correct\n'; +export const CHECKBOXES = '[ ] Incorrect\n[x] Correct\n'; +export const TEXT_INPUT = 'Type your answer here: ___\n'; +export const NUMERICAL_INPUT = '= 100 +-5\n'; +export const DROPDOWN = '[Dropdown: Option A\nOption B\nCorrect Option* ]\n'; +export const EXPLANATION = '>> Add explanation text here <<\n'; diff --git a/src/editors/sharedComponents/CodeEditor/hooks.js b/src/editors/sharedComponents/CodeEditor/hooks.js index a15e64bf15..e174e33bbd 100644 --- a/src/editors/sharedComponents/CodeEditor/hooks.js +++ b/src/editors/sharedComponents/CodeEditor/hooks.js @@ -97,6 +97,7 @@ export const createCodeMirrorDomNode = ({ initialText, upstreamRef, lang, + onReady, }) => { // eslint-disable-next-line react-hooks/rules-of-hooks useEffect(() => { @@ -117,6 +118,7 @@ export const createCodeMirrorDomNode = ({ const view = new EditorView({ state: newState, parent: ref.current }); // eslint-disable-next-line no-param-reassign upstreamRef.current = view; + onReady?.(view); view.focus(); return () => { diff --git a/src/editors/sharedComponents/CodeEditor/index.jsx b/src/editors/sharedComponents/CodeEditor/index.jsx index 7d86ae0587..836abc474b 100644 --- a/src/editors/sharedComponents/CodeEditor/index.jsx +++ b/src/editors/sharedComponents/CodeEditor/index.jsx @@ -1,4 +1,4 @@ -import React, { useRef } from 'react'; +import React, { useRef, useState } from 'react'; import PropTypes from 'prop-types'; import { @@ -6,6 +6,8 @@ import { } from '@openedx/paragon'; import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n'; +import MarkdownToolbar from './EditorToolbar'; + import messages from './messages'; import './index.scss'; @@ -19,13 +21,17 @@ const CodeEditor = ({ const intl = useIntl(); const DOMref = useRef(); const btnRef = useRef(); + + const [editor, setEditor] = useState(null); + hooks.createCodeMirrorDomNode({ - ref: DOMref, initialText: value, upstreamRef: innerRef, lang, + ref: DOMref, initialText: value, upstreamRef: innerRef, lang, onReady: setEditor, }); const { showBtnEscapeHTML, hideBtn } = hooks.prepareShowBtnEscapeHTML(); return (
+ {lang === 'markdown' && }
{showBtnEscapeHTML && lang !== 'markdown' && (