Skip to content

Commit 1e25877

Browse files
committed
feat(editor): add headings and lists plugins
1 parent 7af2c65 commit 1e25877

9 files changed

Lines changed: 649 additions & 13 deletions

File tree

apps/sandbox-e2e/src/example.spec.ts

Lines changed: 74 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,26 @@ test('renders the configured plugin toolbar', async ({ page }) => {
88
).toBeVisible();
99

1010
const toolbar = page.getByRole('toolbar', { name: 'Editor toolbar' });
11+
const paragraph = toolbar.getByRole('button', { name: 'P' });
12+
const heading1 = toolbar.getByRole('button', { name: 'H1' });
13+
const heading2 = toolbar.getByRole('button', { name: 'H2' });
14+
const heading3 = toolbar.getByRole('button', { name: 'H3' });
1115
const underline = toolbar.getByRole('button', { name: 'U' });
16+
const bulletList = toolbar.getByRole('button', { name: 'Bullet list' });
17+
const orderedList = toolbar.getByRole('button', { name: 'Ordered list' });
18+
const liftListItem = toolbar.getByRole('button', { name: 'Lift list item' });
19+
const sinkListItem = toolbar.getByRole('button', { name: 'Sink list item' });
1220
const link = toolbar.getByRole('button', { name: 'Link' });
1321
const unlink = toolbar.getByRole('button', { name: 'Unlink' });
1422
const undo = toolbar.getByRole('button', { name: 'Undo' });
1523
const redo = toolbar.getByRole('button', { name: 'Redo' });
1624

1725
await expect(page.locator('rte-editor > button')).toHaveCount(0);
18-
await expect(toolbar.getByRole('button')).toHaveCount(8);
26+
await expect(toolbar.getByRole('button')).toHaveCount(16);
27+
await expect(paragraph).toHaveAttribute('title', 'Paragraph');
28+
await expect(heading1).toHaveAttribute('title', 'Heading 1');
29+
await expect(heading2).toHaveAttribute('title', 'Heading 2');
30+
await expect(heading3).toHaveAttribute('title', 'Heading 3');
1931
await expect(toolbar.getByRole('button', { name: 'B' })).toHaveAttribute(
2032
'title',
2133
'Bold',
@@ -30,21 +42,77 @@ test('renders the configured plugin toolbar', async ({ page }) => {
3042
'title',
3143
'Strikethrough',
3244
);
45+
await expect(bulletList).toHaveAttribute('title', 'Bullet list');
46+
await expect(bulletList).toBeDisabled();
47+
await expect(orderedList).toHaveAttribute('title', 'Ordered list');
48+
await expect(orderedList).toBeDisabled();
49+
await expect(liftListItem).toHaveAttribute('title', 'Lift list item');
50+
await expect(liftListItem).toBeDisabled();
51+
await expect(sinkListItem).toHaveAttribute('title', 'Sink list item');
52+
await expect(sinkListItem).toBeDisabled();
3353
await expect(link).toHaveAttribute('title', 'Link');
3454
await expect(link).toBeEnabled();
3555
await expect(unlink).toHaveAttribute('title', 'Unlink');
3656
await expect(unlink).toBeDisabled();
3757
await expect(undo).toBeDisabled();
3858
await expect(redo).toBeDisabled();
3959

40-
await expect(toolbar.getByRole('button', { name: 'H1' })).toHaveCount(0);
41-
await expect(toolbar.getByRole('button', { name: 'UL' })).toHaveCount(0);
4260
await expect(page.locator('.ProseMirror')).toContainText('Angular RTE');
61+
await expect(page.locator('pre')).toContainText(
62+
'<h1><strong>Angular RTE</strong></h1>',
63+
);
64+
await expect(page.locator('pre')).toContainText(
65+
'<ul><li><p>Compose plugins in TypeScript.</p></li><li><p>Keep toolbar markup in the consuming app.</p></li></ul>',
66+
);
67+
await expect(page.locator('pre')).toContainText(
68+
'<ol><li><p>Pick capabilities for the current product surface.</p></li><li><p>Render controls with Angular templates and rteCommand.</p></li></ol>',
69+
);
4370
await expect(page.locator('pre')).toContainText('<u>underline</u>');
4471
await expect(page.locator('pre')).toContainText(
4572
'<a href="https://angular.dev" target="_blank" rel="noopener noreferrer">links</a>',
4673
);
4774

75+
await page
76+
.locator('.ProseMirror')
77+
.getByText('Build headless editing primitives')
78+
.selectText();
79+
await heading2.click();
80+
await expect(heading2).toHaveAttribute('aria-pressed', 'true');
81+
await expect(page.locator('pre')).toContainText(
82+
'<h2>Build headless editing primitives with a plugin stack that remains fully selected by the consumer.</h2>',
83+
);
84+
await paragraph.click();
85+
await expect(page.locator('pre')).toContainText(
86+
'<p>Build headless editing primitives with a plugin stack that remains fully selected by the consumer.</p>',
87+
);
88+
await expect(bulletList).toBeEnabled();
89+
await expect(orderedList).toBeEnabled();
90+
91+
await bulletList.click();
92+
await expect(bulletList).toHaveAttribute('aria-pressed', 'true');
93+
await expect(page.locator('pre')).toContainText(
94+
'<ul><li><p>Build headless editing primitives with a plugin stack that remains fully selected by the consumer.</p></li></ul>',
95+
);
96+
await page.locator('.ProseMirror').press('Tab');
97+
await expect(page.locator('.ProseMirror')).toBeFocused();
98+
await page.locator('.ProseMirror').press('Escape');
99+
await expect(page.locator('.ProseMirror')).not.toBeFocused();
100+
await page
101+
.locator('.ProseMirror')
102+
.getByText('Build headless editing primitives')
103+
.selectText();
104+
105+
await orderedList.click();
106+
await expect(orderedList).toHaveAttribute('aria-pressed', 'true');
107+
await expect(page.locator('pre')).toContainText(
108+
'<ol><li><p>Build headless editing primitives with a plugin stack that remains fully selected by the consumer.</p></li></ol>',
109+
);
110+
111+
await orderedList.click();
112+
await expect(page.locator('pre')).toContainText(
113+
'<p>Build headless editing primitives with a plugin stack that remains fully selected by the consumer.</p>',
114+
);
115+
48116
await underline.click();
49117
await expect(underline).toHaveAttribute('aria-pressed', 'true');
50118

@@ -65,7 +133,7 @@ test('renders the configured plugin toolbar', async ({ page }) => {
65133
.getByRole('button', { name: 'Save' })
66134
.click();
67135
await expect(page.locator('pre')).toContainText(
68-
'<strong><a href="/docs" target="_blank" rel="noopener noreferrer">Angular RTE</a></strong>',
136+
'<h1><strong><a href="/docs" target="_blank" rel="noopener noreferrer">Angular RTE</a></strong></h1>',
69137
);
70138
await expect(unlink).toBeEnabled();
71139

@@ -83,7 +151,7 @@ test('renders the configured plugin toolbar', async ({ page }) => {
83151
.getByRole('button', { name: 'Save' })
84152
.click();
85153
await expect(page.locator('pre')).toContainText(
86-
'<strong><a href="/guide" target="_blank" rel="noopener noreferrer">Angular RTE</a></strong>',
154+
'<h1><strong><a href="/guide" target="_blank" rel="noopener noreferrer">Angular RTE</a></strong></h1>',
87155
);
88156

89157
const popupPromise = page.waitForEvent('popup');
@@ -98,7 +166,7 @@ test('renders the configured plugin toolbar', async ({ page }) => {
98166
.getByRole('button', { name: 'Unlink' })
99167
.click();
100168
await expect(page.locator('pre')).toContainText(
101-
'<strong>Angular RTE</strong>',
169+
'<h1><strong>Angular RTE</strong></h1>',
102170
);
103171

104172
await page.locator('.ProseMirror').pressSequentially('X');

apps/sandbox/src/app/app.spec.ts

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import { TestBed } from '@angular/core/testing';
22
import {
3+
HEADINGS_PLUGIN_DEFAULT_OPTIONS,
4+
HeadingsPlugin,
35
HISTORY_PLUGIN_DEFAULT_OPTIONS,
46
HistoryPlugin,
57
LINK_PLUGIN_DEFAULT_OPTIONS,
@@ -24,12 +26,44 @@ describe('App', () => {
2426
'ProseMirror editor foundation',
2527
);
2628
expect(compiled.querySelectorAll('[role="toolbar"] button')).toHaveLength(
27-
8,
29+
16,
2830
);
2931
expect(compiled.querySelector('[aria-label="Link URL"]')).toBeNull();
3032
expect(compiled.querySelector('.ProseMirror')?.textContent).toContain(
3133
'Angular RTE',
3234
);
35+
expect(compiled.querySelector('.ProseMirror ul')?.textContent).toContain(
36+
'Compose plugins in TypeScript.',
37+
);
38+
expect(compiled.querySelector('.ProseMirror ol')?.textContent).toContain(
39+
'Pick capabilities for the current product surface.',
40+
);
41+
});
42+
43+
it('should expose configurable headings defaults and validation', () => {
44+
const configured = HeadingsPlugin.configure({
45+
levels: [2, 3, 4],
46+
});
47+
48+
expect(HEADINGS_PLUGIN_DEFAULT_OPTIONS).toEqual({
49+
levels: [1, 2, 3],
50+
});
51+
expect(HeadingsPlugin.options).toEqual(HEADINGS_PLUGIN_DEFAULT_OPTIONS);
52+
expect(configured.options).toEqual({
53+
levels: [2, 3, 4],
54+
});
55+
expect(() =>
56+
HeadingsPlugin.configure({
57+
levels: [],
58+
}),
59+
).toThrowError(
60+
'HeadingsPlugin levels must include at least one heading level.',
61+
);
62+
expect(() =>
63+
HeadingsPlugin.configure({
64+
levels: [1, 1],
65+
}),
66+
).toThrowError('HeadingsPlugin levels entries must be unique.');
3367
});
3468

3569
it('should expose configurable history defaults', () => {

apps/sandbox/src/app/sandbox-editor.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
import { Component } from '@angular/core';
22
import {
3+
HeadingsPlugin,
34
HistoryPlugin,
45
LinkPlugin,
6+
ListsPlugin,
57
RteContent,
68
RteEditor,
79
createRteEditor,
@@ -26,7 +28,7 @@ import { SandboxToolbar } from './sandbox-toolbar';
2628
/>
2729
2830
<rte-content
29-
class="block min-h-64 p-4 [&_.ProseMirror]:min-h-56 [&_.ProseMirror]:break-words [&_.ProseMirror]:whitespace-pre-wrap [&_.ProseMirror]:outline-none [&_.ProseMirror_p]:mb-3"
31+
class="block min-h-64 p-4 [&_.ProseMirror]:min-h-56 [&_.ProseMirror]:break-words [&_.ProseMirror]:whitespace-pre-wrap [&_.ProseMirror]:outline-none [&_.ProseMirror_h1]:mb-3 [&_.ProseMirror_h1]:text-3xl [&_.ProseMirror_h1]:font-extrabold [&_.ProseMirror_h2]:mb-3 [&_.ProseMirror_h2]:text-2xl [&_.ProseMirror_h2]:font-bold [&_.ProseMirror_h3]:mb-2.5 [&_.ProseMirror_h3]:text-xl [&_.ProseMirror_h3]:font-bold [&_.ProseMirror_li>p]:mb-1 [&_.ProseMirror_ol]:mb-3 [&_.ProseMirror_ol]:list-decimal [&_.ProseMirror_ol]:pl-6 [&_.ProseMirror_p]:mb-3 [&_.ProseMirror_ul]:mb-3 [&_.ProseMirror_ul]:list-disc [&_.ProseMirror_ul]:pl-6"
3032
(mouseover)="linkPopover.showPreview($event)"
3133
(mouseout)="linkPopover.scheduleHideFromEvent($event)"
3234
(focus)="linkPopover.showPreview($event)"
@@ -60,11 +62,13 @@ import { SandboxToolbar } from './sandbox-toolbar';
6062
export class SandboxEditor {
6163
protected readonly editor = createRteEditor({
6264
content:
63-
'<p><strong>Angular RTE</strong> starts with inline text formatting.</p><p>Try <em>italic</em>, <u>underline</u>, <s>strikethrough</s>, and <a href="https://angular.dev" target="_blank" rel="noopener noreferrer">links</a> from a consumer-owned toolbar.</p>',
65+
'<h1><strong>Angular RTE</strong></h1><p>Build headless editing primitives with a plugin stack that remains fully selected by the consumer.</p><p>Use the toolbar to shape content without surrendering UI ownership: try <em>italic</em>, <u>underline</u>, <s>strikethrough</s>, and <a href="https://angular.dev" target="_blank" rel="noopener noreferrer">links</a>.</p><ul><li><p>Compose plugins in TypeScript.</p></li><li><p>Keep toolbar markup in the consuming app.</p></li></ul><ol><li><p>Pick capabilities for the current product surface.</p></li><li><p>Render controls with Angular templates and rteCommand.</p></li></ol><p>Switch paragraphs into lists, nest items with Tab, and lift them back out with Shift+Tab.</p>',
6466
placeholder: 'Start writing...',
6567
plugins: [
68+
HeadingsPlugin,
6669
...TextFormattingKit,
6770
LinkPlugin,
71+
ListsPlugin,
6872
HistoryPlugin.configure({
6973
depth: 200,
7074
newGroupDelay: 750,

apps/sandbox/src/app/sandbox-toolbar.ts

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,39 @@ import { RteCommand, RteEditorController, RteToolbar } from '@angular-rte/editor
88
<rte-toolbar
99
class="flex flex-wrap items-center gap-1.5 border-b border-slate-200 bg-slate-50 p-2"
1010
>
11+
<button
12+
type="button"
13+
[class]="commandClass"
14+
rteCommand="setParagraph"
15+
title="Paragraph"
16+
>
17+
P
18+
</button>
19+
<button
20+
type="button"
21+
[class]="commandClass"
22+
rteCommand="toggleHeading1"
23+
title="Heading 1"
24+
>
25+
H1
26+
</button>
27+
<button
28+
type="button"
29+
[class]="commandClass"
30+
rteCommand="toggleHeading2"
31+
title="Heading 2"
32+
>
33+
H2
34+
</button>
35+
<button
36+
type="button"
37+
[class]="commandClass"
38+
rteCommand="toggleHeading3"
39+
title="Heading 3"
40+
>
41+
H3
42+
</button>
43+
<span class="mx-1 h-5 w-px bg-slate-300" aria-hidden="true"></span>
1144
<button
1245
type="button"
1346
[class]="commandClass"
@@ -41,6 +74,43 @@ import { RteCommand, RteEditorController, RteToolbar } from '@angular-rte/editor
4174
<s>S</s>
4275
</button>
4376
<span class="mx-1 h-5 w-px bg-slate-300" aria-hidden="true"></span>
77+
<button
78+
type="button"
79+
[class]="commandClass"
80+
rteCommand="toggleBulletList"
81+
title="Bullet list"
82+
aria-label="Bullet list"
83+
>
84+
UL
85+
</button>
86+
<button
87+
type="button"
88+
[class]="commandClass"
89+
rteCommand="toggleOrderedList"
90+
title="Ordered list"
91+
aria-label="Ordered list"
92+
>
93+
OL
94+
</button>
95+
<button
96+
type="button"
97+
[class]="commandClass"
98+
rteCommand="liftListItem"
99+
title="Lift list item"
100+
aria-label="Lift list item"
101+
>
102+
Out
103+
</button>
104+
<button
105+
type="button"
106+
[class]="commandClass"
107+
rteCommand="sinkListItem"
108+
title="Sink list item"
109+
aria-label="Sink list item"
110+
>
111+
In
112+
</button>
113+
<span class="mx-1 h-5 w-px bg-slate-300" aria-hidden="true"></span>
44114
<button
45115
type="button"
46116
[class]="commandClass"

libs/editor/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ export * from './lib/editor/editor';
44
export * from './lib/editor/rte-editor-controller';
55
export * from './lib/editor/toolbar';
66
export * from './lib/plugins/history';
7+
export * from './lib/plugins/headings';
78
export * from './lib/plugins/link';
9+
export * from './lib/plugins/lists';
810
export * from './lib/plugins/rte-plugin';
911
export * from './lib/plugins/text-formatting';

0 commit comments

Comments
 (0)