Skip to content

Commit 5f1f774

Browse files
Ahtesham QuraishAhtesham Quraish
authored andcommitted
feat: Add formatting buttons to new Markdown editor #2073
1 parent ab645ad commit 5f1f774

File tree

7 files changed

+323
-2
lines changed

7 files changed

+323
-2
lines changed
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import React from 'react';
2+
import { Button, Icon } from '@openedx/paragon';
3+
import PropTypes from 'prop-types';
4+
import { EditorView } from '@codemirror/view';
5+
import { useIntl } from '@edx/frontend-platform/i18n';
6+
import {
7+
CheckBoxIcon, Lightbulb, ArrowDropDown, ViewList,
8+
} from '@openedx/paragon/icons';
9+
10+
import messages from './messages';
11+
import {
12+
HEADING, MULTIPLE_CHOICE, CHECKBOXES, TEXT_INPUT, NUMERICAL_INPUT, DROPDOWN, EXPLANATION,
13+
} from './constants';
14+
15+
import './EditorToolbar.scss';
16+
17+
const EditorToolbar = ({ editorRef }) => {
18+
const intl = useIntl();
19+
const cm = editorRef;
20+
21+
const insertAtCursor = (text) => {
22+
if (!cm) { return; }
23+
const { from, to } = cm.state.selection.main;
24+
cm.dispatch({
25+
changes: { from, to, insert: text },
26+
selection: { anchor: from + text.length },
27+
});
28+
cm.focus();
29+
};
30+
31+
return (
32+
<div className="editor-toolbar">
33+
<Button type="button" className="toolbar-button" onClick={() => insertAtCursor(HEADING)}>
34+
<span className="editor-icon">{'<H>'}</span> {intl.formatMessage(messages.editorToolbarHeadingButtonLabel)}
35+
</Button>
36+
37+
<Button type="button" className="toolbar-button" onClick={() => insertAtCursor(MULTIPLE_CHOICE)}>
38+
<Icon src={ViewList} size="sm" className="toolbar-icon" /> {intl.formatMessage(messages.editorToolbarMultipleChoiceButtonLabel)}
39+
</Button>
40+
41+
<Button type="button" className="toolbar-button" onClick={() => insertAtCursor(CHECKBOXES)}>
42+
<Icon src={CheckBoxIcon} size="sm" className="toolbar-icon" /> {intl.formatMessage(messages.editorToolbarCheckboxButtonLabel)}
43+
</Button>
44+
45+
<span className="toolbar-separator" />
46+
47+
<Button type="button" className="toolbar-button" onClick={() => insertAtCursor(TEXT_INPUT)}>
48+
<span className="editor-icon">ABC</span> {intl.formatMessage(messages.editorToolbarTextButtonLabel)}
49+
</Button>
50+
51+
<Button type="button" className="toolbar-button" onClick={() => insertAtCursor(NUMERICAL_INPUT)}>
52+
<span className="editor-icon">123</span> {intl.formatMessage(messages.editorToolbarNumericalButtonLabel)}
53+
</Button>
54+
55+
<Button type="button" className="toolbar-button editor-btn-dropdown" onClick={() => insertAtCursor(DROPDOWN)}>
56+
<Icon src={ArrowDropDown} size="sm" className="toolbar-icon" /> {intl.formatMessage(messages.editorToolbarDropdownButtonLabel)}
57+
</Button>
58+
59+
<span className="toolbar-separator" />
60+
61+
<Button type="button" className="toolbar-button" onClick={() => insertAtCursor(EXPLANATION)}>
62+
<Icon src={Lightbulb} size="sm" className="toolbar-icon" /> {intl.formatMessage(messages.editorToolbarExplanationButtonLabel)}
63+
</Button>
64+
</div>
65+
);
66+
};
67+
68+
EditorToolbar.propTypes = {
69+
editorRef: PropTypes.oneOfType([
70+
PropTypes.shape({ current: PropTypes.instanceOf(EditorView) }),
71+
PropTypes.instanceOf(EditorView),
72+
]),
73+
};
74+
75+
EditorToolbar.defaultProps = {
76+
editorRef: null,
77+
};
78+
79+
export default EditorToolbar;
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
.editor-toolbar {
2+
display: flex;
3+
align-items: center;
4+
gap: 4px;
5+
padding: 6px;
6+
background: #D1DAE5;
7+
border: 1px solid #D0D5DD;
8+
border-bottom: none;
9+
border-radius: 4px 4px 0 0;
10+
11+
.toolbar-button {
12+
display: flex;
13+
align-items: center;
14+
gap: 4px;
15+
background: transparent;
16+
border: none;
17+
border-radius: 3px;
18+
padding: 6px 5px;
19+
font-size: 11px;
20+
font-weight: 500;
21+
color: #222222;
22+
cursor: pointer;
23+
transition: background .2s;
24+
25+
svg {
26+
font-size: 14px;
27+
}
28+
29+
&:hover {
30+
background: #202021;
31+
}
32+
33+
&:active {
34+
background: #CBD5E1;
35+
}
36+
}
37+
38+
.toolbar-separator {
39+
width: 1px;
40+
height: 20px;
41+
background: #CCCCCC;
42+
margin: 0 4px;
43+
}
44+
45+
.editor-icon {
46+
font-weight: bold;
47+
font-size: 12px;
48+
}
49+
50+
.editor-btn-dropdown {
51+
gap: 0;
52+
}
53+
}
Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
import React from 'react';
2+
import { render, screen, fireEvent } from '@testing-library/react';
3+
import { IntlProvider } from '@edx/frontend-platform/i18n';
4+
import EditorToolbar from './EditorToolbar';
5+
import messages from './messages';
6+
7+
describe('<EditorToolbar />', () => {
8+
let mockDispatch;
9+
let mockFocus;
10+
let mockEditor;
11+
12+
const renderWithIntl = (ui) => render(
13+
<IntlProvider
14+
locale="en"
15+
messages={Object.fromEntries(
16+
Object.entries(messages).map(([, v]) => [v.id, v.defaultMessage]),
17+
)}
18+
>
19+
{ui}
20+
</IntlProvider>,
21+
);
22+
23+
beforeEach(() => {
24+
mockDispatch = jest.fn();
25+
mockFocus = jest.fn();
26+
mockEditor = {
27+
state: {
28+
selection: { main: { from: 0, to: 0 } },
29+
},
30+
dispatch: mockDispatch,
31+
focus: mockFocus,
32+
};
33+
});
34+
35+
it('renders all toolbar buttons', () => {
36+
renderWithIntl(<EditorToolbar editorRef={mockEditor} />);
37+
38+
expect(screen.getByRole('button', { name: /Heading/i })).toBeInTheDocument();
39+
expect(screen.getByRole('button', { name: /Multiple Choice/i })).toBeInTheDocument();
40+
expect(screen.getByRole('button', { name: /Checkboxes/i })).toBeInTheDocument();
41+
expect(screen.getByRole('button', { name: /Text Input/i })).toBeInTheDocument();
42+
expect(screen.getByRole('button', { name: /Numerical Input/i })).toBeInTheDocument();
43+
expect(screen.getByRole('button', { name: /Dropdown/i })).toBeInTheDocument();
44+
expect(screen.getByRole('button', { name: /Explanation/i })).toBeInTheDocument();
45+
});
46+
47+
it('inserts heading text when heading button is clicked', () => {
48+
renderWithIntl(<EditorToolbar editorRef={mockEditor} />);
49+
fireEvent.click(screen.getByRole('button', { name: /Heading/i }));
50+
51+
expect(mockDispatch).toHaveBeenCalledWith(
52+
expect.objectContaining({
53+
changes: expect.objectContaining({ insert: '## Heading\n\n' }),
54+
}),
55+
);
56+
expect(mockFocus).toHaveBeenCalled();
57+
});
58+
59+
it('inserts multiple choice when multiple choice button is clicked', () => {
60+
renderWithIntl(<EditorToolbar editorRef={mockEditor} />);
61+
fireEvent.click(screen.getByRole('button', { name: /Multiple Choice/i }));
62+
63+
expect(mockDispatch).toHaveBeenCalledWith(
64+
expect.objectContaining({
65+
changes: expect.objectContaining({
66+
insert: expect.stringContaining('( ) Option 1'),
67+
}),
68+
}),
69+
);
70+
});
71+
72+
it('inserts checkboxes when checkboxes button is clicked', () => {
73+
renderWithIntl(<EditorToolbar editorRef={mockEditor} />);
74+
fireEvent.click(screen.getByRole('button', { name: /Checkboxes/i }));
75+
76+
expect(mockDispatch).toHaveBeenCalledWith(
77+
expect.objectContaining({
78+
changes: expect.objectContaining({
79+
insert: expect.stringContaining('[ ] Incorrect'),
80+
}),
81+
}),
82+
);
83+
});
84+
85+
it('inserts text input when text input button is clicked', () => {
86+
renderWithIntl(<EditorToolbar editorRef={mockEditor} />);
87+
fireEvent.click(screen.getByRole('button', { name: /Text Input/i }));
88+
89+
expect(mockDispatch).toHaveBeenCalledWith(
90+
expect.objectContaining({
91+
changes: expect.objectContaining({
92+
insert: expect.stringContaining('Type your answer here:'),
93+
}),
94+
}),
95+
);
96+
});
97+
98+
it('inserts numerical input when numerical input button is clicked', () => {
99+
renderWithIntl(<EditorToolbar editorRef={mockEditor} />);
100+
fireEvent.click(screen.getByRole('button', { name: /Numerical Input/i }));
101+
102+
expect(mockDispatch).toHaveBeenCalledWith(
103+
expect.objectContaining({
104+
changes: expect.objectContaining({
105+
insert: expect.stringContaining('= 100'),
106+
}),
107+
}),
108+
);
109+
});
110+
111+
it('inserts dropdown when dropdown button is clicked', () => {
112+
renderWithIntl(<EditorToolbar editorRef={mockEditor} />);
113+
fireEvent.click(screen.getByRole('button', { name: /Dropdown/i }));
114+
115+
expect(mockDispatch).toHaveBeenCalledWith(
116+
expect.objectContaining({
117+
changes: expect.objectContaining({
118+
insert: expect.stringContaining('[Dropdown:'),
119+
}),
120+
}),
121+
);
122+
});
123+
124+
it('inserts explanation when explanation button is clicked', () => {
125+
renderWithIntl(<EditorToolbar editorRef={mockEditor} />);
126+
fireEvent.click(screen.getByRole('button', { name: /Explanation/i }));
127+
128+
expect(mockDispatch).toHaveBeenCalledWith(
129+
expect.objectContaining({
130+
changes: expect.objectContaining({
131+
insert: expect.stringContaining('>> Add explanation'),
132+
}),
133+
}),
134+
);
135+
});
136+
});

src/editors/sharedComponents/CodeEditor/constants.js

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,3 +34,11 @@ const alphanumericMap = {
3434
quot: '"',
3535
};
3636
export default alphanumericMap;
37+
38+
export const HEADING = '## Heading\n\n';
39+
export const MULTIPLE_CHOICE = '( ) Option 1\n( ) Option 2\n(x) Correct\n';
40+
export const CHECKBOXES = '[ ] Incorrect\n[x] Correct\n';
41+
export const TEXT_INPUT = 'Type your answer here: ___\n';
42+
export const NUMERICAL_INPUT = '= 100 +-5\n';
43+
export const DROPDOWN = '[Dropdown: Option A\nOption B\nCorrect Option* ]\n';
44+
export const EXPLANATION = '>> Add explanation text here <<\n';

src/editors/sharedComponents/CodeEditor/hooks.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,7 @@ export const createCodeMirrorDomNode = ({
9797
initialText,
9898
upstreamRef,
9999
lang,
100+
onReady,
100101
}) => {
101102
// eslint-disable-next-line react-hooks/rules-of-hooks
102103
useEffect(() => {
@@ -117,6 +118,9 @@ export const createCodeMirrorDomNode = ({
117118
const view = new EditorView({ state: newState, parent: ref.current });
118119
// eslint-disable-next-line no-param-reassign
119120
upstreamRef.current = view;
121+
if (onReady) {
122+
onReady(view);
123+
}
120124
view.focus();
121125

122126
return () => {

src/editors/sharedComponents/CodeEditor/index.jsx

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
1-
import React, { useRef } from 'react';
1+
import React, { useRef, useState } from 'react';
22
import PropTypes from 'prop-types';
33

44
import {
55
Button,
66
} from '@openedx/paragon';
77

88
import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n';
9+
import MarkdownToolbar from './EditorToolbar';
10+
911
import messages from './messages';
1012
import './index.scss';
1113

@@ -19,13 +21,17 @@ const CodeEditor = ({
1921
const intl = useIntl();
2022
const DOMref = useRef();
2123
const btnRef = useRef();
24+
25+
const [editor, setEditor] = useState(null);
26+
2227
hooks.createCodeMirrorDomNode({
23-
ref: DOMref, initialText: value, upstreamRef: innerRef, lang,
28+
ref: DOMref, initialText: value, upstreamRef: innerRef, lang, onReady: setEditor,
2429
});
2530
const { showBtnEscapeHTML, hideBtn } = hooks.prepareShowBtnEscapeHTML();
2631

2732
return (
2833
<div>
34+
{lang === 'markdown' && <MarkdownToolbar editorRef={editor} />}
2935
<div id="CodeMirror" ref={DOMref} />
3036
{showBtnEscapeHTML && lang !== 'markdown' && (
3137
<Button

src/editors/sharedComponents/CodeEditor/messages.js

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,41 @@ const messages = defineMessages({
77
defaultMessage: 'Unescape HTML Literals',
88
description: 'Label For escape special html charectars button',
99
},
10+
editorToolbarHeadingButtonLabel: {
11+
id: 'authoring.texteditor.codeEditor.toolbar.headingButton',
12+
defaultMessage: 'Heading',
13+
description: 'Label for toolbar heading button',
14+
},
15+
editorToolbarMultipleChoiceButtonLabel: {
16+
id: 'authoring.texteditor.codeEditor.toolbar.multipleChoiceButton',
17+
defaultMessage: 'Multiple Choice',
18+
description: 'Label for toolbar multiple choice button',
19+
},
20+
editorToolbarCheckboxButtonLabel: {
21+
id: 'authoring.texteditor.codeEditor.toolbar.checkboxButton',
22+
defaultMessage: 'Checkboxes',
23+
description: 'Label for toolbar checkboxes button',
24+
},
25+
editorToolbarTextButtonLabel: {
26+
id: 'authoring.texteditor.codeEditor.toolbar.textButton',
27+
defaultMessage: 'Text Input',
28+
description: 'Label for toolbar text button',
29+
},
30+
editorToolbarNumericalButtonLabel: {
31+
id: 'authoring.texteditor.codeEditor.toolbar.numericalButton',
32+
defaultMessage: 'Numerical Input',
33+
description: 'Label for toolbar numerical button',
34+
},
35+
editorToolbarDropdownButtonLabel: {
36+
id: 'authoring.texteditor.codeEditor.toolbar.dropdownButton',
37+
defaultMessage: 'Dropdown',
38+
description: 'Label for toolbar dropdown button',
39+
},
40+
editorToolbarExplanationButtonLabel: {
41+
id: 'authoring.texteditor.codeEditor.toolbar.explanationButton',
42+
defaultMessage: 'Explanation',
43+
description: 'Label for toolbar explanation button',
44+
},
1045
});
1146

1247
export default messages;

0 commit comments

Comments
 (0)