diff --git a/projects/ngx-editor/src/lib/commands/HorizontalRule.ts b/projects/ngx-editor/src/lib/commands/HorizontalRule.ts
index 703bce9..4277d3e 100644
--- a/projects/ngx-editor/src/lib/commands/HorizontalRule.ts
+++ b/projects/ngx-editor/src/lib/commands/HorizontalRule.ts
@@ -1,5 +1,5 @@
import type { NodeType } from 'prosemirror-model';
-import type { EditorState, Transaction, Command } from 'prosemirror-state';
+import { type EditorState, type Transaction, type Command, TextSelection } from 'prosemirror-state';
import { canInsert } from 'ngx-editor/helpers';
@@ -16,7 +16,43 @@ class HorizontalRule implements InsertCommand {
return false;
}
- dispatch(tr.replaceSelectionWith(type.create()).scrollIntoView());
+ const { $from } = state.selection;
+ const parentNode = $from.parent;
+ const parentStart = $from.before($from.depth);
+ const parentEnd = $from.after($from.depth);
+
+ const contentBefore = parentNode.cut(0, $from.parentOffset);
+ const contentAfter = parentNode.cut($from.parentOffset);
+
+ const nodes = [];
+
+ if (contentBefore.content.size === 0 && contentAfter.content.size === 0) {
+ // Empty paragraph: replace with HR + empty paragraph so cursor has
+ // somewhere to land and the placeholder doesn't show on the
.
+ nodes.push(type.create());
+ nodes.push(schema.nodes['paragraph'].createAndFill());
+ } else {
+ // Non-empty paragraph: preserve text on both sides of cursor.
+ if (contentBefore.content.size > 0) {
+ nodes.push(contentBefore);
+ }
+ nodes.push(type.create());
+ if (contentAfter.content.size > 0) {
+ nodes.push(contentAfter);
+ } else {
+ // Only add a trailing empty paragraph if this is the last block,
+ // so the cursor has somewhere to land.
+ const isLastChild = parentEnd >= state.doc.content.size;
+ if (isLastChild) {
+ nodes.push(schema.nodes['paragraph'].createAndFill());
+ }
+ }
+ }
+
+ tr.replaceWith(parentStart, parentEnd, nodes);
+ tr.setSelection(TextSelection.near(tr.doc.resolve(tr.mapping.map(parentEnd))));
+
+ dispatch(tr.scrollIntoView());
return true;
};
}
diff --git a/projects/ngx-editor/src/lib/editor.spec.ts b/projects/ngx-editor/src/lib/editor.spec.ts
index 5c82060..f5eeb25 100644
--- a/projects/ngx-editor/src/lib/editor.spec.ts
+++ b/projects/ngx-editor/src/lib/editor.spec.ts
@@ -1,4 +1,6 @@
import Editor from './Editor';
+import { HORIZONTAL_RULE } from './commands';
+import { TextSelection } from 'prosemirror-state';
describe('Editor', () => {
it('should create the editor correctly', () => {
@@ -83,3 +85,87 @@ describe('Editor: Commands', () => {
expect(editor.view.state.doc.textContent).toBe('Hello there');
});
});
+
+describe('Editor: HorizontalRule', () => {
+ it('should insert a horizontal rule followed by a paragraph on an empty editor', () => {
+ const editor = new Editor();
+ const { state } = editor.view;
+
+ HORIZONTAL_RULE.insert()(state, editor.view.dispatch.bind(editor.view));
+
+ const { doc } = editor.view.state;
+ expect(doc.childCount).toBe(2);
+ expect(doc.child(0).type.name).toBe('horizontal_rule');
+ expect(doc.child(1).type.name).toBe('paragraph');
+ });
+
+ it('should insert a horizontal rule in the middle of content', () => {
+ const editor = new Editor({ content: 'Hello' });
+ const { state } = editor.view;
+
+ HORIZONTAL_RULE.insert()(state, editor.view.dispatch.bind(editor.view));
+
+ const { doc } = editor.view.state;
+ expect(doc.child(0).type.name).toBe('horizontal_rule');
+ expect(doc.child(1).type.name).toBe('paragraph');
+ });
+
+ it('should preserve text when cursor is at the end of a non-empty paragraph', () => {
+ const editor = new Editor({ content: 'Hello' });
+
+ // Place cursor at the end of "Hello" (position 6: doc=0, p=1, H=1,e=2,l=3,l=4,o=5, end=6)
+ const endPos = editor.view.state.doc.content.size - 1; // end of text inside paragraph
+ const tr = editor.view.state.tr.setSelection(TextSelection.create(editor.view.state.doc, endPos));
+ editor.view.dispatch(tr);
+
+ const { state } = editor.view;
+ HORIZONTAL_RULE.insert()(state, editor.view.dispatch.bind(editor.view));
+
+ const { doc } = editor.view.state;
+ // The original paragraph with "Hello" must still exist
+ expect(doc.child(0).type.name).toBe('paragraph');
+ expect(doc.child(0).textContent).toBe('Hello');
+ // Followed by a horizontal rule and a new paragraph
+ expect(doc.child(1).type.name).toBe('horizontal_rule');
+ expect(doc.child(2).type.name).toBe('paragraph');
+ });
+
+ it('should not add trailing empty paragraph when next sibling exists', () => {
+ const editor = new Editor({ content: 'First
Second
' });
+
+ // Place cursor at the end of "First"
+ const endPos = editor.view.state.doc.child(0).nodeSize - 1; // end of text in first paragraph
+ const tr = editor.view.state.tr.setSelection(TextSelection.create(editor.view.state.doc, endPos));
+ editor.view.dispatch(tr);
+
+ const { state } = editor.view;
+ HORIZONTAL_RULE.insert()(state, editor.view.dispatch.bind(editor.view));
+
+ const { doc } = editor.view.state;
+ expect(doc.childCount).toBe(3);
+ expect(doc.child(0).type.name).toBe('paragraph');
+ expect(doc.child(0).textContent).toBe('First');
+ expect(doc.child(1).type.name).toBe('horizontal_rule');
+ expect(doc.child(2).type.name).toBe('paragraph');
+ expect(doc.child(2).textContent).toBe('Second');
+ });
+
+ it('should split text when cursor is in the middle of a paragraph', () => {
+ const editor = new Editor({ content: 'Hello World' });
+
+ // Place cursor between "Hello" and " World" (position 6, after "Hello")
+ const tr = editor.view.state.tr.setSelection(TextSelection.create(editor.view.state.doc, 6));
+ editor.view.dispatch(tr);
+
+ const { state } = editor.view;
+ HORIZONTAL_RULE.insert()(state, editor.view.dispatch.bind(editor.view));
+
+ const { doc } = editor.view.state;
+ // "Hello" paragraph, then HR, then " World" paragraph
+ expect(doc.child(0).type.name).toBe('paragraph');
+ expect(doc.child(0).textContent).toBe('Hello');
+ expect(doc.child(1).type.name).toBe('horizontal_rule');
+ expect(doc.child(2).type.name).toBe('paragraph');
+ expect(doc.child(2).textContent).toBe(' World');
+ });
+});