Skip to content

Commit d2ad48b

Browse files
committed
feat(editor): add text alignment plugin
1 parent d18edbe commit d2ad48b

11 files changed

Lines changed: 678 additions & 30 deletions

File tree

.agents/skills/angular-rte-architecture/references/current-architecture.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ an explicit escape hatch.
4343
An `RtePlugin` has a unique `key` and can contribute:
4444

4545
- `nodes`
46+
- `extendNodes(nodes)`
4647
- `marks`
4748
- `commands(schema)`
4849
- `commandStates(schema)`

.agents/skills/angular-rte-plugin-development/references/plugin-contract.md

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ as the configurable engine-plugin example.
2121
| ---------------------------- | --------------------------------------------------------- |
2222
| `key` | Stable unique identity for the Angular RTE plugin. |
2323
| `nodes` | Add optional ProseMirror node specs to the editor schema. |
24+
| `extendNodes(nodes)` | Return amended specs for existing schema nodes. |
2425
| `marks` | Add optional ProseMirror mark specs to the editor schema. |
2526
| `commands(schema)` | Register consumer-executable named commands. |
2627
| `commandStates(schema)` | Report meaningful active state for named commands. |
@@ -63,11 +64,13 @@ A node plugin normally needs:
6364

6465
1. A `NodeSpec` with correct content expression, group, parse rules, and DOM
6566
serialization.
66-
2. Commands for inserting, toggling, wrapping, lifting, or changing node type.
67-
3. State queries only when they provide useful UI state.
68-
4. Read-only queries only when consumer UI needs structured state.
69-
5. Keyboard behavior that composes with the base keymap.
70-
6. Tests for schema validity, parsing, serialization, and editing behavior.
67+
2. `extendNodes` when a feature needs to add attributes or serialization
68+
behavior to an existing schema node such as `paragraph`.
69+
3. Commands for inserting, toggling, wrapping, lifting, or changing node type.
70+
4. State queries only when they provide useful UI state.
71+
5. Read-only queries only when consumer UI needs structured state.
72+
6. Keyboard behavior that composes with the base keymap.
73+
7. Tests for schema validity, parsing, serialization, and editing behavior.
7174

7275
Do not introduce an Angular node view until the feature needs Angular-specific
7376
interactive rendering.

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

Lines changed: 54 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,22 @@ 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' });
15-
const underline = toolbar.getByRole('button', { name: 'U' });
11+
const paragraph = toolbar.getByRole('button', { name: 'Paragraph' });
12+
const heading1 = toolbar.getByRole('button', { name: 'Heading 1' });
13+
const heading2 = toolbar.getByRole('button', { name: 'Heading 2' });
14+
const heading3 = toolbar.getByRole('button', { name: 'Heading 3' });
15+
const alignLeft = toolbar.getByRole('button', { name: 'Align left' });
16+
const alignCenter = toolbar.getByRole('button', { name: 'Align center' });
17+
const alignRight = toolbar.getByRole('button', { name: 'Align right' });
18+
const justify = toolbar.getByRole('button', { name: 'Justify' });
19+
const underline = toolbar.getByRole('button', { name: 'Underline' });
20+
const clearFormatting = toolbar.getByRole('button', {
21+
name: 'Clear formatting',
22+
});
1623
const bulletList = toolbar.getByRole('button', { name: 'Bullet list' });
1724
const orderedList = toolbar.getByRole('button', { name: 'Ordered list' });
1825
const blockquote = toolbar.getByRole('button', { name: 'Blockquote' });
26+
const codeBlock = toolbar.getByRole('button', { name: 'Code block' });
1927
const liftListItem = toolbar.getByRole('button', { name: 'Lift list item' });
2028
const sinkListItem = toolbar.getByRole('button', { name: 'Sink list item' });
2129
const link = toolbar.getByRole('button', { name: 'Link' });
@@ -24,31 +32,42 @@ test('renders the configured plugin toolbar', async ({ page }) => {
2432
const redo = toolbar.getByRole('button', { name: 'Redo' });
2533

2634
await expect(page.locator('rte-editor > button')).toHaveCount(0);
27-
await expect(toolbar.getByRole('button')).toHaveCount(17);
35+
await expect(toolbar.getByRole('button')).toHaveCount(23);
2836
await expect(paragraph).toHaveAttribute('title', 'Paragraph');
2937
await expect(heading1).toHaveAttribute('title', 'Heading 1');
3038
await expect(heading2).toHaveAttribute('title', 'Heading 2');
3139
await expect(heading3).toHaveAttribute('title', 'Heading 3');
32-
await expect(toolbar.getByRole('button', { name: 'B' })).toHaveAttribute(
40+
await expect(alignLeft).toHaveAttribute('title', 'Align left');
41+
await expect(alignLeft).toBeEnabled();
42+
await expect(alignCenter).toHaveAttribute('title', 'Align center');
43+
await expect(alignCenter).toBeEnabled();
44+
await expect(alignRight).toHaveAttribute('title', 'Align right');
45+
await expect(alignRight).toBeEnabled();
46+
await expect(justify).toHaveAttribute('title', 'Justify');
47+
await expect(justify).toBeEnabled();
48+
await expect(toolbar.getByRole('button', { name: 'Bold' })).toHaveAttribute(
3349
'title',
3450
'Bold',
3551
);
36-
await expect(toolbar.getByRole('button', { name: 'I' })).toHaveAttribute(
52+
await expect(toolbar.getByRole('button', { name: 'Italic' })).toHaveAttribute(
3753
'title',
3854
'Italic',
3955
);
4056
await expect(underline).toHaveAttribute('title', 'Underline');
4157
await expect(underline).toBeEnabled();
42-
await expect(toolbar.getByRole('button', { name: 'S' })).toHaveAttribute(
43-
'title',
44-
'Strikethrough',
45-
);
58+
await expect(
59+
toolbar.getByRole('button', { name: 'Strikethrough' }),
60+
).toHaveAttribute('title', 'Strikethrough');
61+
await expect(clearFormatting).toHaveAttribute('title', 'Clear formatting');
62+
await expect(clearFormatting).toBeEnabled();
4663
await expect(bulletList).toHaveAttribute('title', 'Bullet list');
4764
await expect(bulletList).toBeDisabled();
4865
await expect(orderedList).toHaveAttribute('title', 'Ordered list');
4966
await expect(orderedList).toBeDisabled();
5067
await expect(blockquote).toHaveAttribute('title', 'Blockquote');
5168
await expect(blockquote).toBeEnabled();
69+
await expect(codeBlock).toHaveAttribute('title', 'Code block');
70+
await expect(codeBlock).toBeEnabled();
5271
await expect(liftListItem).toHaveAttribute('title', 'Lift list item');
5372
await expect(liftListItem).toBeDisabled();
5473
await expect(sinkListItem).toHaveAttribute('title', 'Sink list item');
@@ -61,6 +80,9 @@ test('renders the configured plugin toolbar', async ({ page }) => {
6180
await expect(redo).toBeDisabled();
6281

6382
await expect(page.locator('.ProseMirror')).toContainText('Angular RTE');
83+
await expect(page.locator('pre')).toContainText(
84+
'<p style="text-align: center;">Build headless editing primitives with a plugin stack that remains fully selected by the consumer.</p>',
85+
);
6486
await expect(page.locator('pre')).toContainText(
6587
'<h1><strong>Angular RTE</strong></h1>',
6688
);
@@ -91,6 +113,15 @@ test('renders the configured plugin toolbar', async ({ page }) => {
91113
await expect(page.locator('pre')).toContainText(
92114
'<p>Build headless editing primitives with a plugin stack that remains fully selected by the consumer.</p>',
93115
);
116+
await alignRight.click();
117+
await expect(alignRight).toHaveAttribute('aria-pressed', 'true');
118+
await expect(page.locator('pre')).toContainText(
119+
'<p style="text-align: right;">Build headless editing primitives with a plugin stack that remains fully selected by the consumer.</p>',
120+
);
121+
await alignLeft.click();
122+
await expect(page.locator('pre')).toContainText(
123+
'<p>Build headless editing primitives with a plugin stack that remains fully selected by the consumer.</p>',
124+
);
94125
await expect(bulletList).toBeEnabled();
95126
await expect(orderedList).toBeEnabled();
96127

@@ -99,6 +130,14 @@ test('renders the configured plugin toolbar', async ({ page }) => {
99130
await expect(page.locator('pre')).toContainText(
100131
'<ul><li><p>Build headless editing primitives with a plugin stack that remains fully selected by the consumer.</p></li></ul>',
101132
);
133+
await alignRight.click();
134+
await expect(page.locator('pre')).toContainText(
135+
'<ul><li style="text-align: right;"><p>Build headless editing primitives with a plugin stack that remains fully selected by the consumer.</p></li></ul>',
136+
);
137+
await alignLeft.click();
138+
await expect(page.locator('pre')).toContainText(
139+
'<ul><li><p>Build headless editing primitives with a plugin stack that remains fully selected by the consumer.</p></li></ul>',
140+
);
102141
await page.locator('.ProseMirror').press('Tab');
103142
await expect(page.locator('.ProseMirror')).toBeFocused();
104143
await page.locator('.ProseMirror').press('Escape');
@@ -134,7 +173,9 @@ test('renders the configured plugin toolbar', async ({ page }) => {
134173
await expect(underline).toHaveAttribute('aria-pressed', 'true');
135174

136175
await page.locator('.ProseMirror a[href="https://angular.dev"]').hover();
137-
await expect(page.getByRole('dialog', { name: 'Link preview' })).toBeVisible();
176+
await expect(
177+
page.getByRole('dialog', { name: 'Link preview' }),
178+
).toBeVisible();
138179
await expect(
139180
page.getByRole('dialog', { name: 'Link preview' }).getByRole('link'),
140181
).toContainText('https://angular.dev');

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

Lines changed: 124 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,12 @@ import {
1111
HistoryPlugin,
1212
LINK_PLUGIN_DEFAULT_OPTIONS,
1313
LinkPlugin,
14+
ListsPlugin,
15+
TEXT_ALIGN_PLUGIN_DEFAULT_OPTIONS,
16+
TextAlignPlugin,
1417
TextFormattingKit,
1518
createRteEditor,
19+
createRtePlugin,
1620
} from '@angular-rte/editor';
1721

1822
import { App } from './app';
@@ -34,9 +38,14 @@ describe('App', () => {
3438
'ProseMirror editor foundation',
3539
);
3640
expect(compiled.querySelectorAll('[role="toolbar"] button')).toHaveLength(
37-
19,
41+
23,
3842
);
39-
expect(compiled.querySelector('[aria-label="Clear formatting"]')).not.toBeNull();
43+
expect(
44+
compiled.querySelector('[aria-label="Clear formatting"]'),
45+
).not.toBeNull();
46+
expect(
47+
compiled.querySelector('[aria-label="Align center"]'),
48+
).not.toBeNull();
4049
expect(compiled.querySelector('[aria-label="Link URL"]')).toBeNull();
4150
expect(compiled.querySelector('.ProseMirror')?.textContent).toContain(
4251
'Angular RTE',
@@ -60,8 +69,9 @@ describe('App', () => {
6069
compiled.querySelector('.ProseMirror code.language-go')?.textContent,
6170
).toContain('fmt.Println');
6271
expect(
63-
compiled.querySelector('.ProseMirror code.language-typescript .hljs-keyword')
64-
?.textContent,
72+
compiled.querySelector(
73+
'.ProseMirror code.language-typescript .hljs-keyword',
74+
)?.textContent,
6575
).toContain('import');
6676
expect(
6777
compiled.querySelector('.ProseMirror code.language-go .hljs-keyword')
@@ -323,9 +333,7 @@ describe('App', () => {
323333
editor.mount(host);
324334

325335
expect(editor.execute('clearFormatting')).toBeTrue();
326-
expect(editor.html()).toBe(
327-
'<p>const first = 1;<br>const second = 2;</p>',
328-
);
336+
expect(editor.html()).toBe('<p>const first = 1;<br>const second = 2;</p>');
329337

330338
editor.unmount(host);
331339
});
@@ -366,7 +374,8 @@ describe('App', () => {
366374

367375
it('should keep Tab inside code blocks for indentation', () => {
368376
const editor = createRteEditor({
369-
content: '<pre><code class="language-typescript">const answer = 42;</code></pre>',
377+
content:
378+
'<pre><code class="language-typescript">const answer = 42;</code></pre>',
370379
plugins: [
371380
CodeBlockPlugin.configure({
372381
languages: ['plaintext', 'typescript'],
@@ -477,6 +486,113 @@ describe('App', () => {
477486
editor.unmount(host);
478487
});
479488

489+
it('should expose text alignment through the public plugin', () => {
490+
const editor = createRteEditor({
491+
content: '<p>Aligned text</p>',
492+
plugins: [TextAlignPlugin, HeadingsPlugin],
493+
});
494+
const host = document.createElement('div');
495+
496+
editor.mount(host);
497+
498+
expect(TextAlignPlugin.key).toBe('textAlign');
499+
expect(editor.canExecute('setTextAlignCenter')).toBeTrue();
500+
expect(editor.isCommandActive('setTextAlignLeft')).toBeTrue();
501+
expect(editor.query('textAlign')).toBe('left');
502+
expect(editor.execute('setTextAlignCenter')).toBeTrue();
503+
expect(editor.isCommandActive('setTextAlignCenter')).toBeTrue();
504+
expect(editor.query('textAlign')).toBe('center');
505+
expect(editor.html()).toBe(
506+
'<p style="text-align: center;">Aligned text</p>',
507+
);
508+
expect(editor.execute('setTextAlignLeft')).toBeTrue();
509+
expect(editor.html()).toBe('<p>Aligned text</p>');
510+
511+
editor.setHtml('<h2 style="text-align: right;">Aligned heading</h2>');
512+
513+
expect(editor.isCommandActive('setTextAlignRight')).toBeTrue();
514+
expect(editor.query('textAlign')).toBe('right');
515+
expect(editor.execute('setTextAlignJustify')).toBeTrue();
516+
expect(editor.html()).toBe(
517+
'<h2 style="text-align: justify;">Aligned heading</h2>',
518+
);
519+
520+
editor.unmount(host);
521+
});
522+
523+
it('should align list items and blockquotes at the container level', () => {
524+
const editor = createRteEditor({
525+
content:
526+
'<ul><li><p>Aligned list item</p></li></ul><blockquote><p>Aligned quote</p></blockquote>',
527+
plugins: [TextAlignPlugin, ListsPlugin, BlockquotePlugin],
528+
});
529+
const host = document.createElement('div');
530+
531+
editor.mount(host);
532+
533+
expect(editor.execute('setTextAlignCenter')).toBeTrue();
534+
expect(editor.html()).toContain(
535+
'<li style="text-align: center;"><p>Aligned list item</p></li>',
536+
);
537+
expect(editor.execute('setTextAlignLeft')).toBeTrue();
538+
expect(editor.html()).toContain('<li><p>Aligned list item</p></li>');
539+
540+
editor.setHtml('<blockquote><p>Aligned quote</p></blockquote>');
541+
542+
expect(editor.execute('setTextAlignRight')).toBeTrue();
543+
expect(editor.html()).toBe(
544+
'<blockquote style="text-align: right;"><p>Aligned quote</p></blockquote>',
545+
);
546+
547+
editor.unmount(host);
548+
});
549+
550+
it('should expose configurable text alignment defaults and validation', () => {
551+
const configured = TextAlignPlugin.configure({
552+
alignments: ['left', 'center'],
553+
nodes: ['paragraph'],
554+
});
555+
556+
expect(TEXT_ALIGN_PLUGIN_DEFAULT_OPTIONS).toEqual({
557+
alignments: ['left', 'center', 'right', 'justify'],
558+
nodes: ['paragraph', 'heading', 'listItem', 'blockquote'],
559+
});
560+
expect(TextAlignPlugin.options).toEqual(TEXT_ALIGN_PLUGIN_DEFAULT_OPTIONS);
561+
expect(configured.options).toEqual({
562+
alignments: ['left', 'center'],
563+
nodes: ['paragraph'],
564+
});
565+
expect(() =>
566+
TextAlignPlugin.configure({
567+
alignments: [],
568+
}),
569+
).toThrowError(
570+
'TextAlignPlugin alignments must include at least one value.',
571+
);
572+
expect(() =>
573+
TextAlignPlugin.configure({
574+
alignments: ['center', 'center'],
575+
}),
576+
).toThrowError('TextAlignPlugin alignments entries must be unique.');
577+
expect(() =>
578+
TextAlignPlugin.configure({
579+
nodes: ['paragraph', 'paragraph'],
580+
}),
581+
).toThrowError('TextAlignPlugin nodes entries must be unique.');
582+
expect(() =>
583+
createRteEditor({
584+
plugins: [
585+
createRtePlugin({
586+
key: 'badNodeExtension',
587+
extendNodes: () => ({ missing: { content: 'inline*' } }),
588+
}),
589+
],
590+
}),
591+
).toThrowError(
592+
'RTE plugin "badNodeExtension" extends unknown node "missing".',
593+
);
594+
});
595+
480596
it('should expose configurable headings defaults and validation', () => {
481597
const configured = HeadingsPlugin.configure({
482598
levels: [2, 3, 4],

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

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
ListsPlugin,
1111
RteContent,
1212
RteEditor,
13+
TextAlignPlugin,
1314
createRteEditor,
1415
TextFormattingKit,
1516
} from '@angular-rte/editor';
@@ -70,10 +71,11 @@ import { SandboxToolbar } from './sandbox-toolbar';
7071
export class SandboxEditor {
7172
protected readonly editor = createRteEditor({
7273
content:
73-
'<h1><strong>Angular RTE</strong></h1><p>Build headless editing primitives with a plugin stack that remains fully selected by the consumer.</p><blockquote><p>Quote important passages without taking ownership away from the consuming app.</p></blockquote><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><pre><code class="language-typescript">import { createRteEditor } from "@angular-rte/editor";&#10;&#10;const editor = createRteEditor({&#10; plugins: [CodeBlockPlugin],&#10;});&#10;&#10;editor.execute("setCodeBlockLanguage", "typescript");</code></pre><pre><code class="language-go">package main&#10;&#10;import "fmt"&#10;&#10;func main() {&#10; fmt.Println("Angular RTE")&#10;}</code></pre><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>',
74+
'<h1><strong>Angular RTE</strong></h1><p style="text-align: center;">Build headless editing primitives with a plugin stack that remains fully selected by the consumer.</p><blockquote><p>Quote important passages without taking ownership away from the consuming app.</p></blockquote><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><pre><code class="language-typescript">import { createRteEditor } from "@angular-rte/editor";&#10;&#10;const editor = createRteEditor({&#10; plugins: [CodeBlockPlugin],&#10;});&#10;&#10;editor.execute("setCodeBlockLanguage", "typescript");</code></pre><pre><code class="language-go">package main&#10;&#10;import "fmt"&#10;&#10;func main() {&#10; fmt.Println("Angular RTE")&#10;}</code></pre><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>',
7475
placeholder: 'Start writing...',
7576
plugins: [
7677
HeadingsPlugin,
78+
TextAlignPlugin,
7779
...TextFormattingKit,
7880
LinkPlugin,
7981
ListsPlugin,

0 commit comments

Comments
 (0)