Skip to content

Commit fa6cf1a

Browse files
committed
feat(editor): add clear formatting plugin
1 parent 3c7efb4 commit fa6cf1a

5 files changed

Lines changed: 227 additions & 1 deletion

File tree

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

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,15 @@ import { TestBed } from '@angular/core/testing';
22
import {
33
BlockquotePlugin,
44
CODE_BLOCK_PLUGIN_DEFAULT_OPTIONS,
5+
ClearFormattingPlugin,
56
CodeBlockPlugin,
67
HEADINGS_PLUGIN_DEFAULT_OPTIONS,
78
HeadingsPlugin,
89
HISTORY_PLUGIN_DEFAULT_OPTIONS,
910
HistoryPlugin,
1011
LINK_PLUGIN_DEFAULT_OPTIONS,
1112
LinkPlugin,
13+
TextFormattingKit,
1214
createRteEditor,
1315
} from '@angular-rte/editor';
1416

@@ -31,8 +33,9 @@ describe('App', () => {
3133
'ProseMirror editor foundation',
3234
);
3335
expect(compiled.querySelectorAll('[role="toolbar"] button')).toHaveLength(
34-
18,
36+
19,
3537
);
38+
expect(compiled.querySelector('[aria-label="Clear formatting"]')).not.toBeNull();
3639
expect(compiled.querySelector('[aria-label="Link URL"]')).toBeNull();
3740
expect(compiled.querySelector('.ProseMirror')?.textContent).toContain(
3841
'Angular RTE',
@@ -160,6 +163,56 @@ describe('App', () => {
160163
editor.unmount(host);
161164
});
162165

166+
it('should expose clear formatting through the public plugin', () => {
167+
const editor = createRteEditor({
168+
content:
169+
'<h2><strong><em><a href="https://angular.dev">Angular RTE</a></em></strong></h2>',
170+
plugins: [
171+
HeadingsPlugin,
172+
...TextFormattingKit,
173+
LinkPlugin,
174+
ClearFormattingPlugin,
175+
],
176+
});
177+
const host = document.createElement('div');
178+
179+
editor.mount(host);
180+
181+
expect(ClearFormattingPlugin.key).toBe('clearFormatting');
182+
expect(editor.execute('selectLink')).toBeTrue();
183+
expect(editor.canExecute('clearFormatting')).toBeTrue();
184+
expect(editor.execute('clearFormatting')).toBeTrue();
185+
expect(editor.html()).toBe('<p>Angular RTE</p>');
186+
expect(editor.canExecute('clearFormatting')).toBeFalse();
187+
expect(editor.execute('clearFormatting')).toBeFalse();
188+
189+
editor.unmount(host);
190+
});
191+
192+
it('should clear code block formatting to a paragraph', () => {
193+
const editor = createRteEditor({
194+
content:
195+
'<pre><code class="language-typescript">const answer = 42;</code></pre>',
196+
plugins: [
197+
CodeBlockPlugin.configure({
198+
languages: ['plaintext', 'typescript'],
199+
defaultLanguage: 'typescript',
200+
}),
201+
ClearFormattingPlugin,
202+
],
203+
});
204+
const host = document.createElement('div');
205+
206+
editor.mount(host);
207+
208+
expect(editor.canExecute('clearFormatting')).toBeTrue();
209+
expect(editor.execute('clearFormatting')).toBeTrue();
210+
expect(editor.html()).toBe('<p>const answer = 42;</p>');
211+
expect(editor.isCommandActive('toggleCodeBlock')).toBeFalse();
212+
213+
editor.unmount(host);
214+
});
215+
163216
it('should parse serialized code blocks through the public plugin', () => {
164217
const snippet = [
165218
'package main',

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { Component } from '@angular/core';
22
import {
33
BlockquotePlugin,
4+
ClearFormattingPlugin,
45
CodeBlockPlugin,
56
HeadingsPlugin,
67
HistoryPlugin,
@@ -80,6 +81,7 @@ export class SandboxEditor {
8081
languages: SANDBOX_CODE_BLOCK_LANGUAGE_VALUES,
8182
defaultLanguage: SANDBOX_DEFAULT_CODE_BLOCK_LANGUAGE,
8283
}),
84+
ClearFormattingPlugin,
8385
SandboxCodeHighlightPlugin,
8486
HistoryPlugin.configure({
8587
depth: 200,

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

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { RteCommand, RteEditorController, RteToolbar } from '@angular-rte/editor
99
import { NgIcon, provideIcons } from '@ng-icons/core';
1010
import {
1111
lucideBold,
12+
lucideEraser,
1213
lucideHeading1,
1314
lucideHeading2,
1415
lucideHeading3,
@@ -39,6 +40,7 @@ import {
3940
providers: [
4041
provideIcons({
4142
lucideBold,
43+
lucideEraser,
4244
lucideHeading1,
4345
lucideHeading2,
4446
lucideHeading3,
@@ -136,6 +138,15 @@ import {
136138
>
137139
<ng-icon name="lucideStrikethrough" aria-hidden="true" />
138140
</button>
141+
<button
142+
type="button"
143+
[class]="commandClass"
144+
rteCommand="clearFormatting"
145+
title="Clear formatting"
146+
aria-label="Clear formatting"
147+
>
148+
<ng-icon name="lucideEraser" aria-hidden="true" />
149+
</button>
139150
<span class="mx-1 h-5 w-px bg-slate-300" aria-hidden="true"></span>
140151
<button
141152
type="button"

libs/editor/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ export * from './lib/editor/editor';
44
export * from './lib/editor/rte-editor-controller';
55
export * from './lib/editor/toolbar';
66
export * from './lib/plugins/blockquote';
7+
export * from './lib/plugins/clear-formatting';
78
export * from './lib/plugins/code-block';
89
export * from './lib/plugins/history';
910
export * from './lib/plugins/headings';
Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
import { Node as ProseMirrorNode, NodeType } from 'prosemirror-model';
2+
import { EditorState } from 'prosemirror-state';
3+
4+
import { createRtePlugin, RteCommandHandler, RtePlugin } from './rte-plugin';
5+
6+
export const ClearFormattingPlugin = createRtePlugin({
7+
key: 'clearFormatting',
8+
commands: (schema) => ({
9+
clearFormatting: createClearFormattingCommand(schema.nodes['paragraph']),
10+
}),
11+
});
12+
13+
export const ClearFormattingKit: readonly RtePlugin[] = [
14+
ClearFormattingPlugin,
15+
];
16+
17+
function createClearFormattingCommand(
18+
paragraph: NodeType,
19+
): RteCommandHandler {
20+
return (state, dispatch) => {
21+
const markRanges = getMarkRangesToClear(state);
22+
const hasMarksToClear =
23+
Boolean(state.storedMarks?.length) ||
24+
markRanges.some((range) =>
25+
rangeHasInlineMarks(state.doc, range.from, range.to),
26+
);
27+
const hasTextblocksToClear = selectionHasTextblockFormattingToClear(
28+
state,
29+
paragraph,
30+
);
31+
32+
if (!hasMarksToClear && !hasTextblocksToClear) {
33+
return false;
34+
}
35+
36+
if (dispatch) {
37+
const transaction = state.tr;
38+
39+
if (state.storedMarks?.length) {
40+
transaction.setStoredMarks([]);
41+
}
42+
43+
if (hasMarksToClear) {
44+
for (const range of markRanges) {
45+
if (range.from < range.to) {
46+
transaction.removeMark(range.from, range.to, null);
47+
}
48+
}
49+
}
50+
51+
if (hasTextblocksToClear) {
52+
for (const range of state.selection.ranges) {
53+
transaction.setBlockType(range.$from.pos, range.$to.pos, paragraph);
54+
}
55+
}
56+
57+
dispatch(transaction.scrollIntoView());
58+
}
59+
60+
return true;
61+
};
62+
}
63+
64+
interface DocumentRange {
65+
from: number;
66+
to: number;
67+
}
68+
69+
function getMarkRangesToClear(state: EditorState): readonly DocumentRange[] {
70+
if (!state.selection.empty) {
71+
return state.selection.ranges.map((range) => ({
72+
from: range.$from.pos,
73+
to: range.$to.pos,
74+
}));
75+
}
76+
77+
const { $from } = state.selection;
78+
79+
if ($from.depth === 0 || !$from.parent.inlineContent) {
80+
return [];
81+
}
82+
83+
return [
84+
{
85+
from: $from.start(),
86+
to: $from.end(),
87+
},
88+
];
89+
}
90+
91+
function rangeHasInlineMarks(
92+
doc: ProseMirrorNode,
93+
from: number,
94+
to: number,
95+
): boolean {
96+
if (from >= to) {
97+
return false;
98+
}
99+
100+
let hasMarks = false;
101+
102+
doc.nodesBetween(from, to, (node) => {
103+
if (hasMarks) {
104+
return false;
105+
}
106+
107+
if (node.isInline && node.marks.length > 0) {
108+
hasMarks = true;
109+
110+
return false;
111+
}
112+
113+
return undefined;
114+
});
115+
116+
return hasMarks;
117+
}
118+
119+
function selectionHasTextblockFormattingToClear(
120+
state: EditorState,
121+
paragraph: NodeType,
122+
): boolean {
123+
let applicable = false;
124+
125+
for (const range of state.selection.ranges) {
126+
if (applicable) {
127+
break;
128+
}
129+
130+
state.doc.nodesBetween(range.$from.pos, range.$to.pos, (node, pos) => {
131+
if (applicable) {
132+
return false;
133+
}
134+
135+
if (!node.isTextblock || node.hasMarkup(paragraph)) {
136+
return undefined;
137+
}
138+
139+
if (node.type === paragraph) {
140+
applicable = true;
141+
142+
return false;
143+
}
144+
145+
const $pos = state.doc.resolve(pos);
146+
const index = $pos.index();
147+
148+
if ($pos.parent.canReplaceWith(index, index + 1, paragraph)) {
149+
applicable = true;
150+
151+
return false;
152+
}
153+
154+
return undefined;
155+
});
156+
}
157+
158+
return applicable;
159+
}

0 commit comments

Comments
 (0)