Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 38 additions & 2 deletions projects/ngx-editor/src/lib/commands/HorizontalRule.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -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 <hr>.
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;
};
}
Expand Down
86 changes: 86 additions & 0 deletions projects/ngx-editor/src/lib/editor.spec.ts
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand Down Expand Up @@ -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: '<p>First</p><p>Second</p>' });

// 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');
});
});
Loading