Skip to content

Commit 6052db1

Browse files
authored
Add list functionality to rich text editor (matrix-org#9871)
* adds buttons to toggle bulleted and numbered lists on and off * adds icons for those buttons * css changes to timeline display * adds tests for the new buttons, refactors existing tests
1 parent 7975b07 commit 6052db1

File tree

8 files changed

+142
-67
lines changed

8 files changed

+142
-67
lines changed

res/css/views/rooms/_EventTile.pcss

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -619,6 +619,17 @@ $left-gutter: 64px;
619619
ul ol {
620620
list-style-type: revert;
621621
}
622+
623+
/* Make list type disc to match rich text editor */
624+
> ul {
625+
list-style-type: disc;
626+
}
627+
628+
/* Remove top and bottom margin for better consecutive list display */
629+
> :is(ol, ul) {
630+
margin-top: 0;
631+
margin-bottom: 0;
632+
}
622633
}
623634
}
624635

res/css/views/rooms/wysiwyg_composer/components/_Editor.pcss

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ limitations under the License.
2525
}
2626

2727
.mx_WysiwygComposer_Editor_content {
28+
line-height: $font-22px;
2829
white-space: pre-wrap;
2930
word-wrap: break-word;
3031
outline: none;
@@ -35,6 +36,19 @@ limitations under the License.
3536
.caretNode {
3637
user-select: all;
3738
}
39+
40+
ul,
41+
ol {
42+
margin-top: 0;
43+
margin-bottom: 0;
44+
padding-inline-start: $spacing-28;
45+
}
46+
47+
// model output always includes a linebreak but we do not want the user
48+
// to see it when writing input in lists
49+
:is(ol, ul) + br:last-of-type {
50+
display: none;
51+
}
3852
}
3953

4054
.mx_WysiwygComposer_Editor_content_placeholder::before {
Lines changed: 3 additions & 0 deletions
Loading
Lines changed: 3 additions & 0 deletions
Loading

src/components/views/rooms/wysiwyg_composer/components/Editor.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ import React, { CSSProperties, forwardRef, memo, MutableRefObject, ReactNode } f
2020
import { useIsExpanded } from "../hooks/useIsExpanded";
2121
import { useSelection } from "../hooks/useSelection";
2222

23-
const HEIGHT_BREAKING_POINT = 20;
23+
const HEIGHT_BREAKING_POINT = 24;
2424

2525
interface EditorProps {
2626
disabled: boolean;

src/components/views/rooms/wysiwyg_composer/components/FormattingButtons.tsx

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@ import { Icon as UnderlineIcon } from "../../../../../../res/img/element-icons/r
2424
import { Icon as StrikeThroughIcon } from "../../../../../../res/img/element-icons/room/composer/strikethrough.svg";
2525
import { Icon as InlineCodeIcon } from "../../../../../../res/img/element-icons/room/composer/inline_code.svg";
2626
import { Icon as LinkIcon } from "../../../../../../res/img/element-icons/room/composer/link.svg";
27+
import { Icon as BulletedListIcon } from "../../../../../../res/img/element-icons/room/composer/bulleted_list.svg";
28+
import { Icon as NumberedListIcon } from "../../../../../../res/img/element-icons/room/composer/numbered_list.svg";
2729
import AccessibleTooltipButton from "../../../elements/AccessibleTooltipButton";
2830
import { Alignment } from "../../../elements/Tooltip";
2931
import { KeyboardShortcut } from "../../../settings/KeyboardShortcut";
@@ -109,6 +111,18 @@ export function FormattingButtons({ composer, actionStates }: FormattingButtonsP
109111
onClick={() => composer.strikeThrough()}
110112
icon={<StrikeThroughIcon className="mx_FormattingButtons_Icon" />}
111113
/>
114+
<Button
115+
isActive={actionStates.unorderedList === "reversed"}
116+
label={_td("Bulleted list")}
117+
onClick={() => composer.unorderedList()}
118+
icon={<BulletedListIcon className="mx_FormattingButtons_Icon" />}
119+
/>
120+
<Button
121+
isActive={actionStates.orderedList === "reversed"}
122+
label={_td("Numbered list")}
123+
onClick={() => composer.orderedList()}
124+
icon={<NumberedListIcon className="mx_FormattingButtons_Icon" />}
125+
/>
112126
<Button
113127
isActive={actionStates.inlineCode === "reversed"}
114128
label={_td("Code")}

src/i18n/strings/en_EN.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2135,6 +2135,8 @@
21352135
"Stop recording": "Stop recording",
21362136
"Italic": "Italic",
21372137
"Underline": "Underline",
2138+
"Bulleted list": "Bulleted list",
2139+
"Numbered list": "Numbered list",
21382140
"Code": "Code",
21392141
"Link": "Link",
21402142
"Edit link": "Edit link",

test/components/views/rooms/wysiwyg_composer/components/FormattingButtons-test.tsx

Lines changed: 94 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -17,90 +17,118 @@ limitations under the License.
1717
import React from "react";
1818
import { render, screen } from "@testing-library/react";
1919
import userEvent from "@testing-library/user-event";
20-
import { AllActionStates, FormattingFunctions } from "@matrix-org/matrix-wysiwyg";
20+
import { ActionState, ActionTypes, AllActionStates, FormattingFunctions } from "@matrix-org/matrix-wysiwyg";
2121

2222
import { FormattingButtons } from "../../../../../../src/components/views/rooms/wysiwyg_composer/components/FormattingButtons";
2323
import * as LinkModal from "../../../../../../src/components/views/rooms/wysiwyg_composer/components/LinkModal";
2424

25-
describe("FormattingButtons", () => {
26-
const wysiwyg = {
27-
bold: jest.fn(),
28-
italic: jest.fn(),
29-
underline: jest.fn(),
30-
strikeThrough: jest.fn(),
31-
inlineCode: jest.fn(),
32-
link: jest.fn(),
33-
} as unknown as FormattingFunctions;
34-
35-
const actionStates = {
36-
bold: "reversed",
37-
italic: "reversed",
38-
underline: "enabled",
39-
strikeThrough: "enabled",
40-
inlineCode: "enabled",
41-
link: "enabled",
42-
} as AllActionStates;
25+
const mockWysiwyg = {
26+
bold: jest.fn(),
27+
italic: jest.fn(),
28+
underline: jest.fn(),
29+
strikeThrough: jest.fn(),
30+
inlineCode: jest.fn(),
31+
link: jest.fn(),
32+
orderedList: jest.fn(),
33+
unorderedList: jest.fn(),
34+
} as unknown as FormattingFunctions;
35+
36+
const openLinkModalSpy = jest.spyOn(LinkModal, "openLinkModal");
37+
38+
const testCases: Record<
39+
Exclude<ActionTypes, "undo" | "redo" | "clear">,
40+
{ label: string; mockFormatFn: jest.Func | jest.SpyInstance }
41+
> = {
42+
bold: { label: "Bold", mockFormatFn: mockWysiwyg.bold },
43+
italic: { label: "Italic", mockFormatFn: mockWysiwyg.italic },
44+
underline: { label: "Underline", mockFormatFn: mockWysiwyg.underline },
45+
strikeThrough: { label: "Strikethrough", mockFormatFn: mockWysiwyg.strikeThrough },
46+
inlineCode: { label: "Code", mockFormatFn: mockWysiwyg.inlineCode },
47+
link: { label: "Link", mockFormatFn: openLinkModalSpy },
48+
orderedList: { label: "Numbered list", mockFormatFn: mockWysiwyg.orderedList },
49+
unorderedList: { label: "Bulleted list", mockFormatFn: mockWysiwyg.unorderedList },
50+
};
51+
52+
const createActionStates = (state: ActionState): AllActionStates => {
53+
return Object.fromEntries(Object.keys(testCases).map((testKey) => [testKey, state])) as AllActionStates;
54+
};
55+
56+
const defaultActionStates = createActionStates("enabled");
57+
58+
const renderComponent = (props = {}) => {
59+
return render(<FormattingButtons composer={mockWysiwyg} actionStates={defaultActionStates} {...props} />);
60+
};
61+
62+
const classes = {
63+
active: "mx_FormattingButtons_active",
64+
hover: "mx_FormattingButtons_Button_hover",
65+
};
4366

67+
describe("FormattingButtons", () => {
4468
afterEach(() => {
4569
jest.resetAllMocks();
4670
});
4771

48-
it("Should have the correspond CSS classes", () => {
49-
// When
50-
render(<FormattingButtons composer={wysiwyg} actionStates={actionStates} />);
51-
52-
// Then
53-
expect(screen.getByLabelText("Bold")).toHaveClass("mx_FormattingButtons_active");
54-
expect(screen.getByLabelText("Italic")).toHaveClass("mx_FormattingButtons_active");
55-
expect(screen.getByLabelText("Underline")).not.toHaveClass("mx_FormattingButtons_active");
56-
expect(screen.getByLabelText("Strikethrough")).not.toHaveClass("mx_FormattingButtons_active");
57-
expect(screen.getByLabelText("Code")).not.toHaveClass("mx_FormattingButtons_active");
58-
expect(screen.getByLabelText("Link")).not.toHaveClass("mx_FormattingButtons_active");
72+
it("Each button should not have active class when enabled", () => {
73+
renderComponent();
74+
75+
Object.values(testCases).forEach(({ label }) => {
76+
expect(screen.getByLabelText(label)).not.toHaveClass(classes.active);
77+
});
78+
});
79+
80+
it("Each button should have active class when reversed", () => {
81+
const reversedActionStates = createActionStates("reversed");
82+
renderComponent({ actionStates: reversedActionStates });
83+
84+
Object.values(testCases).forEach((testCase) => {
85+
const { label } = testCase;
86+
expect(screen.getByLabelText(label)).toHaveClass(classes.active);
87+
});
5988
});
6089

61-
it("Should call wysiwyg function on button click", () => {
62-
// When
63-
const spy = jest.spyOn(LinkModal, "openLinkModal");
64-
render(<FormattingButtons composer={wysiwyg} actionStates={actionStates} />);
65-
screen.getByLabelText("Bold").click();
66-
screen.getByLabelText("Italic").click();
67-
screen.getByLabelText("Underline").click();
68-
screen.getByLabelText("Strikethrough").click();
69-
screen.getByLabelText("Code").click();
70-
screen.getByLabelText("Link").click();
71-
72-
// Then
73-
expect(wysiwyg.bold).toHaveBeenCalledTimes(1);
74-
expect(wysiwyg.italic).toHaveBeenCalledTimes(1);
75-
expect(wysiwyg.underline).toHaveBeenCalledTimes(1);
76-
expect(wysiwyg.strikeThrough).toHaveBeenCalledTimes(1);
77-
expect(wysiwyg.inlineCode).toHaveBeenCalledTimes(1);
78-
expect(spy).toHaveBeenCalledTimes(1);
90+
it("Should call wysiwyg function on button click", async () => {
91+
renderComponent();
92+
93+
for (const testCase of Object.values(testCases)) {
94+
const { label, mockFormatFn } = testCase;
95+
96+
screen.getByLabelText(label).click();
97+
expect(mockFormatFn).toHaveBeenCalledTimes(1);
98+
}
7999
});
80100

81-
it("Should display the tooltip on mouse over", async () => {
82-
// When
83-
const user = userEvent.setup();
84-
render(<FormattingButtons composer={wysiwyg} actionStates={actionStates} />);
85-
await user.hover(screen.getByLabelText("Bold"));
101+
it("Each button should display the tooltip on mouse over", async () => {
102+
renderComponent();
103+
104+
for (const testCase of Object.values(testCases)) {
105+
const { label } = testCase;
86106

87-
// Then
88-
expect(await screen.findByText("Bold")).toBeTruthy();
107+
await userEvent.hover(screen.getByLabelText(label));
108+
expect(await screen.findByText(label)).toBeTruthy();
109+
}
89110
});
90111

91-
it("Should not have hover style when active", async () => {
92-
// When
93-
const user = userEvent.setup();
94-
render(<FormattingButtons composer={wysiwyg} actionStates={actionStates} />);
95-
await user.hover(screen.getByLabelText("Bold"));
112+
it("Each button should have hover style when hovered and enabled", async () => {
113+
renderComponent();
114+
115+
for (const testCase of Object.values(testCases)) {
116+
const { label } = testCase;
117+
118+
await userEvent.hover(screen.getByLabelText(label));
119+
expect(screen.getByLabelText(label)).toHaveClass("mx_FormattingButtons_Button_hover");
120+
}
121+
});
96122

97-
// Then
98-
expect(screen.getByLabelText("Bold")).not.toHaveClass("mx_FormattingButtons_Button_hover");
123+
it("Each button should not have hover style when hovered and reversed", async () => {
124+
const reversedActionStates = createActionStates("reversed");
125+
renderComponent({ actionStates: reversedActionStates });
99126

100-
// When
101-
await user.hover(screen.getByLabelText("Underline"));
127+
for (const testCase of Object.values(testCases)) {
128+
const { label } = testCase;
102129

103-
// Then
104-
expect(screen.getByLabelText("Underline")).toHaveClass("mx_FormattingButtons_Button_hover");
130+
await userEvent.hover(screen.getByLabelText(label));
131+
expect(screen.getByLabelText(label)).not.toHaveClass("mx_FormattingButtons_Button_hover");
132+
}
105133
});
106134
});

0 commit comments

Comments
 (0)