Skip to content

Commit d18edbe

Browse files
committed
feat(editor): add hard break plugin
1 parent fa6cf1a commit d18edbe

4 files changed

Lines changed: 177 additions & 0 deletions

File tree

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

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
CODE_BLOCK_PLUGIN_DEFAULT_OPTIONS,
55
ClearFormattingPlugin,
66
CodeBlockPlugin,
7+
HardBreakPlugin,
78
HEADINGS_PLUGIN_DEFAULT_OPTIONS,
89
HeadingsPlugin,
910
HISTORY_PLUGIN_DEFAULT_OPTIONS,
@@ -213,6 +214,122 @@ describe('App', () => {
213214
editor.unmount(host);
214215
});
215216

217+
it('should expose hard break commands through the public plugin', () => {
218+
const editor = createRteEditor({
219+
content: '<p>Angular RTE</p>',
220+
plugins: [HardBreakPlugin],
221+
});
222+
const host = document.createElement('div');
223+
224+
editor.mount(host);
225+
226+
expect(HardBreakPlugin.key).toBe('hardBreak');
227+
expect(editor.canExecute('insertHardBreak')).toBeTrue();
228+
expect(editor.execute('insertHardBreak')).toBeTrue();
229+
expect(editor.html()).toBe('<p><br>Angular RTE</p>');
230+
231+
editor.unmount(host);
232+
});
233+
234+
it('should parse serialized hard breaks through the public plugin', () => {
235+
const editor = createRteEditor({
236+
content: '<p>Line one<br>Line two</p>',
237+
plugins: [HardBreakPlugin],
238+
});
239+
const host = document.createElement('div');
240+
241+
editor.mount(host);
242+
243+
expect(editor.html()).toBe('<p>Line one<br>Line two</p>');
244+
245+
editor.unmount(host);
246+
});
247+
248+
it('should insert hard breaks with Shift+Enter', () => {
249+
const editor = createRteEditor({
250+
content: '<p>Angular RTE</p>',
251+
plugins: [HardBreakPlugin],
252+
});
253+
const host = document.createElement('div');
254+
255+
editor.mount(host);
256+
257+
const surface = host.querySelector('.ProseMirror');
258+
const shiftEnterEvent = new KeyboardEvent('keydown', {
259+
bubbles: true,
260+
cancelable: true,
261+
key: 'Enter',
262+
shiftKey: true,
263+
});
264+
265+
surface?.dispatchEvent(shiftEnterEvent);
266+
267+
expect(shiftEnterEvent.defaultPrevented).toBeTrue();
268+
expect(editor.html()).toBe('<p><br>Angular RTE</p>');
269+
270+
editor.unmount(host);
271+
});
272+
273+
it('should keep Shift+Enter out of code blocks', () => {
274+
const editor = createRteEditor({
275+
content:
276+
'<pre><code class="language-typescript">const answer = 42;</code></pre>',
277+
plugins: [
278+
CodeBlockPlugin.configure({
279+
languages: ['plaintext', 'typescript'],
280+
defaultLanguage: 'typescript',
281+
}),
282+
HardBreakPlugin,
283+
],
284+
});
285+
const host = document.createElement('div');
286+
287+
editor.mount(host);
288+
289+
const surface = host.querySelector('.ProseMirror');
290+
const shiftEnterEvent = new KeyboardEvent('keydown', {
291+
bubbles: true,
292+
cancelable: true,
293+
key: 'Enter',
294+
shiftKey: true,
295+
});
296+
297+
surface?.dispatchEvent(shiftEnterEvent);
298+
299+
expect(shiftEnterEvent.defaultPrevented).toBeFalse();
300+
expect(editor.canExecute('insertHardBreak')).toBeFalse();
301+
expect(editor.html()).toBe(
302+
'<pre><code class="language-typescript">const answer = 42;</code></pre>',
303+
);
304+
305+
editor.unmount(host);
306+
});
307+
308+
it('should preserve code block line breaks when clearing formatting with hard breaks enabled', () => {
309+
const editor = createRteEditor({
310+
content:
311+
'<pre><code class="language-typescript">const first = 1;&#10;const second = 2;</code></pre>',
312+
plugins: [
313+
CodeBlockPlugin.configure({
314+
languages: ['plaintext', 'typescript'],
315+
defaultLanguage: 'typescript',
316+
}),
317+
HardBreakPlugin,
318+
ClearFormattingPlugin,
319+
],
320+
});
321+
const host = document.createElement('div');
322+
323+
editor.mount(host);
324+
325+
expect(editor.execute('clearFormatting')).toBeTrue();
326+
expect(editor.html()).toBe(
327+
'<p>const first = 1;<br>const second = 2;</p>',
328+
);
329+
330+
editor.unmount(host);
331+
});
332+
216333
it('should parse serialized code blocks through the public plugin', () => {
217334
const snippet = [
218335
'package main',

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import {
33
BlockquotePlugin,
44
ClearFormattingPlugin,
55
CodeBlockPlugin,
6+
HardBreakPlugin,
67
HeadingsPlugin,
78
HistoryPlugin,
89
LinkPlugin,
@@ -81,6 +82,7 @@ export class SandboxEditor {
8182
languages: SANDBOX_CODE_BLOCK_LANGUAGE_VALUES,
8283
defaultLanguage: SANDBOX_DEFAULT_CODE_BLOCK_LANGUAGE,
8384
}),
85+
HardBreakPlugin,
8486
ClearFormattingPlugin,
8587
SandboxCodeHighlightPlugin,
8688
HistoryPlugin.configure({

libs/editor/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ export * from './lib/editor/toolbar';
66
export * from './lib/plugins/blockquote';
77
export * from './lib/plugins/clear-formatting';
88
export * from './lib/plugins/code-block';
9+
export * from './lib/plugins/hard-break';
910
export * from './lib/plugins/history';
1011
export * from './lib/plugins/headings';
1112
export * from './lib/plugins/link';
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import { NodeSpec, NodeType } from 'prosemirror-model';
2+
import { EditorState } from 'prosemirror-state';
3+
4+
import { createRtePlugin, RteCommandHandler, RtePlugin } from './rte-plugin';
5+
6+
const hardBreakNode: NodeSpec = {
7+
inline: true,
8+
group: 'inline',
9+
selectable: false,
10+
linebreakReplacement: true,
11+
parseDOM: [{ tag: 'br' }],
12+
toDOM: () => ['br'],
13+
leafText: () => '\n',
14+
};
15+
16+
export const HardBreakPlugin = createRtePlugin({
17+
key: 'hardBreak',
18+
nodes: {
19+
hardBreak: hardBreakNode,
20+
},
21+
commands: (schema) => ({
22+
insertHardBreak: createInsertHardBreakCommand(schema.nodes['hardBreak']),
23+
}),
24+
shortcuts: (schema) => ({
25+
'Shift-Enter': createInsertHardBreakCommand(schema.nodes['hardBreak']),
26+
}),
27+
});
28+
29+
export const HardBreakKit: readonly RtePlugin[] = [HardBreakPlugin];
30+
31+
function createInsertHardBreakCommand(
32+
hardBreak: NodeType,
33+
): RteCommandHandler {
34+
return (state, dispatch) => {
35+
if (!canInsertHardBreak(state, hardBreak)) {
36+
return false;
37+
}
38+
39+
if (dispatch) {
40+
dispatch(
41+
state.tr.replaceSelectionWith(hardBreak.create()).scrollIntoView(),
42+
);
43+
}
44+
45+
return true;
46+
};
47+
}
48+
49+
function canInsertHardBreak(state: EditorState, hardBreak: NodeType): boolean {
50+
const { $from, $to } = state.selection;
51+
52+
return (
53+
$from.sameParent($to) &&
54+
$from.parent.inlineContent &&
55+
$from.parent.canReplaceWith($from.index(), $to.index(), hardBreak)
56+
);
57+
}

0 commit comments

Comments
 (0)