diff --git a/packages/core/src/block/content/atxHeadingContent/__tests__/enterHandler.spec.ts b/packages/core/src/block/content/atxHeadingContent/__tests__/enterHandler.spec.ts new file mode 100644 index 00000000..005a6d5a --- /dev/null +++ b/packages/core/src/block/content/atxHeadingContent/__tests__/enterHandler.spec.ts @@ -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 `` 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); + }); +}); diff --git a/packages/core/src/block/content/atxHeadingContent/index.ts b/packages/core/src/block/content/atxHeadingContent/index.ts index 6efb4d45..86edef5b 100644 --- a/packages/core/src/block/content/atxHeadingContent/index.ts +++ b/packages/core/src/block/content/atxHeadingContent/index.ts @@ -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: '', diff --git a/packages/core/src/selection/index.ts b/packages/core/src/selection/index.ts index 87519c40..25bf5a2e 100644 --- a/packages/core/src/selection/index.ts +++ b/packages/core/src/selection/index.ts @@ -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;