Skip to content
This repository was archived by the owner on May 29, 2026. It is now read-only.
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
// @vitest-environment happy-dom
import { afterEach, describe, expect, it, vi } from 'vitest';
import { ScrollPage } from '../../../scrollPage';
import AtxHeadingContent from '../index';

// Regression: pressing Enter at the start of an ATX heading (e.g. before
// the leading `#`) inserts a new empty paragraph above the heading, but
// the handler used to forget to call `event.preventDefault()`. The
// browser's default Enter behavior then ran on the contenteditable —
// splitting the heading's `<span class="mu-content">` or cloning it —
// producing orphan `mu-content` nodes that were NOT linked back to a
// block via `BLOCK_DOM_PROPERTY`. A subsequent click would resolve to
// such an orphan and `Selection.getSelection` crashed with
// `Cannot read properties of undefined (reading 'path')`.
//
// We exercise the handler directly with a structurally-typed `this` so
// no Muya bootstrap is required.

interface FakeAtxContent {
text: string;
cursor: { start: number; end: number };
parent: {
meta: { level: number };
parent: { insertBefore: (block: unknown, ref: unknown) => void };
};
muya: unknown;
getCursor: () => { start: { offset: number }; end: { offset: number } };
setCursor: (start: number, end: number, _selected?: boolean) => void;
}

function makeFakeContent(level: number, cursorAt: number): FakeAtxContent {
return {
text: '# Headings',
cursor: { start: cursorAt, end: cursorAt },
parent: {
meta: { level },
parent: { insertBefore: vi.fn() },
},
muya: {},
getCursor() {
return {
start: { offset: this.cursor.start },
end: { offset: this.cursor.end },
};
},
setCursor(start, end) {
this.cursor = { start, end };
},
};
}

function makeKeyEvent() {
return {
preventDefault: vi.fn(),
stopPropagation: vi.fn(),
} as unknown as KeyboardEvent;
}

describe('atxHeadingContent.enterHandler — prevents default when inserting paragraph above heading', () => {
afterEach(() => {
vi.restoreAllMocks();
});

it('calls preventDefault when Enter is pressed at offset 0 in an H1', () => {
// Stub ScrollPage.loadBlock so the handler can synthesize a new
// paragraph block without bootstrapping the registry.
const fakeParagraphBlock = {};
const create = vi.fn().mockReturnValue(fakeParagraphBlock);
vi.spyOn(ScrollPage, 'loadBlock').mockReturnValue({ create } as never);

const content = makeFakeContent(1, 0);
const event = makeKeyEvent();

AtxHeadingContent.prototype.enterHandler.call(
content as unknown as AtxHeadingContent,
event,
);

expect(event.preventDefault).toHaveBeenCalledTimes(1);
// Sanity: the paragraph insert path actually ran.
expect(content.parent.parent.insertBefore).toHaveBeenCalledTimes(1);
});

it('calls preventDefault when Enter is pressed right after the `#` in an H1 (offset 1)', () => {
const fakeParagraphBlock = {};
const create = vi.fn().mockReturnValue(fakeParagraphBlock);
vi.spyOn(ScrollPage, 'loadBlock').mockReturnValue({ create } as never);

// For level=1, the guard is `offset <= level + 1` (=2), so offset
// 1 still routes through the insert-paragraph branch.
const content = makeFakeContent(1, 1);
const event = makeKeyEvent();

AtxHeadingContent.prototype.enterHandler.call(
content as unknown as AtxHeadingContent,
event,
);

expect(event.preventDefault).toHaveBeenCalledTimes(1);
expect(content.parent.parent.insertBefore).toHaveBeenCalledTimes(1);
});
});
9 changes: 9 additions & 0 deletions packages/core/src/block/content/atxHeadingContent/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,15 @@ class AtxHeadingContent extends Format {
const { level } = this.parent!.meta;

if (start.offset === end.offset && start.offset <= level + 1) {
// Without preventDefault the browser still runs its native
// Enter behavior on the contenteditable after we insert the
// new paragraph, which can split or clone the heading's
// `mu-content` span. Orphaned spans lack BLOCK_DOM_PROPERTY,
// so a later click crashes Selection.getSelection at
// `anchorBlock.path`.
event.preventDefault();
event.stopPropagation();

const newNodeState = {
name: 'paragraph',
text: '',
Expand Down
9 changes: 7 additions & 2 deletions packages/core/src/selection/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -226,8 +226,13 @@ class Selection {
if (!anchorDomNode || !focusDomNode)
return null;

const anchorBlock = anchorDomNode[BLOCK_DOM_PROPERTY] as Content;
const focusBlock = focusDomNode[BLOCK_DOM_PROPERTY] as Content;
const anchorBlock = anchorDomNode[BLOCK_DOM_PROPERTY] as Content | undefined;
const focusBlock = focusDomNode[BLOCK_DOM_PROPERTY] as Content | undefined;
// An `mu-content` span cloned by the browser's native edit
// behavior is not linked back to a block. Bail out instead of
// crashing — the caller treats null the same as "no selection".
if (!anchorBlock || !focusBlock)
return null;
const anchorPath = anchorBlock.path;
const focusPath = focusBlock.path;

Expand Down
Loading