Preserve modified Enter line breaks in Markdown tables (#35224)

* Preserve modified Enter line breaks in Markdown tables

* fix(editor): limit table hard-break bypass to Tiptap's exact shortcuts

---------

Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
This commit is contained in:
OrcaWin
2026-10-29 16:17:26 -07:00
committed by GitHub
co-authored by OrcaWin
parent fe4ceec83f
commit bb2980c290
3 changed files with 126 additions and 5 deletions
@@ -1,10 +1,14 @@
// @vitest-environment happy-dom
import { describe, expect, it, vi } from 'vitest'
import { Editor, type JSONContent } from '@tiptap/core'
import { Editor, isMacOS, type JSONContent } from '@tiptap/core'
import StarterKit from '@tiptap/starter-kit'
import { CellSelection } from '@tiptap/pm/tables'
import { createIsolatedMarkdownExtensionForTests } from './isolated-markdown-extension-for-tests'
import { createRichMarkdownKeyHandler, type KeyHandlerContext } from './rich-markdown-key-handler'
import { createRichMarkdownExtensions } from './rich-markdown-extensions'
import { createRichMarkdownEditorCodec } from './rich-markdown-source-transport'
import { encodeRawMarkdownHtmlForRichEditor } from './raw-markdown-html'
import { commitRichMarkdownSerialization } from './rich-markdown-serialization-commit'
// Pin the shortcut platform because navigator.userAgent varies across test environments.
vi.mock('@/lib/shortcut-platform', () => ({
@@ -423,6 +427,105 @@ describe('rich markdown key handler', () => {
}
})
it.each([true, false])('leaves the primary-modifier table Enter to Tiptap (Mac: %s)', (isMac) => {
const editor = createTableEditor()
try {
editor.commands.setTextSelection(caretAtText(editor, 'a1') + 1)
const ctx = createContext(editor, false)
ctx.isMac = isMac
const event = keyEvent('Enter', isMac ? { metaKey: true } : { ctrlKey: true })
const before = editor.state
expect(createRichMarkdownKeyHandler(ctx)(null, event)).toBe(false)
expect(event.preventDefault).not.toHaveBeenCalled()
expect(editor.state).toBe(before)
} finally {
editor.destroy()
}
})
it('keeps Shift+Enter navigation for a multi-cell selection that cannot take a break', () => {
const editor = createTableEditor()
try {
const cellAt = (text: string) => {
const $text = editor.state.doc.resolve(caretAtText(editor, text))
return $text.before($text.depth - 1)
}
const first = cellAt('a1')
const last = cellAt('b1')
editor.view.dispatch(
editor.state.tr.setSelection(CellSelection.create(editor.state.doc, first, last))
)
const event = keyEvent('Enter', { shiftKey: true })
expect(createRichMarkdownKeyHandler(createContext(editor, false))(null, event)).toBe(true)
expect(editor.state.selection.$from.parent.textContent).toBe('b2')
} finally {
editor.destroy()
}
})
it.each(['Shift-Enter', 'Mod-Enter'])(
'preserves the table line break inserted by %s through save and reopen',
(shortcut) => {
const editor = createTableEditor()
const element = document.createElement('div')
document.body.append(element)
editor.mount(element)
try {
editor.commands.setTextSelection(caretAtText(editor, 'a1') + 1)
const ctx = createContext(editor, false)
ctx.isMac = isMacOS()
editor.setOptions({ editorProps: { handleKeyDown: createRichMarkdownKeyHandler(ctx) } })
const originalSourceRef = { current: TABLE_MARKDOWN }
const baseCanonicalRef = { current: editor.getMarkdown() }
const lastCommittedMarkdownRef = { current: TABLE_MARKDOWN }
expect(editor.commands.keyboardShortcut(shortcut)).toBe(true)
expect(editor.state.selection.$from.parent.child(1).type.name).toBe('hardBreak')
const reopen = (source: string): Editor => {
const codec = createRichMarkdownEditorCodec()
return new Editor({
element: null,
extensions: createRichMarkdownExtensions({ codec }),
content: encodeRawMarkdownHtmlForRichEditor(source, codec),
contentType: 'markdown'
})
}
const saved = commitRichMarkdownSerialization(
editor,
{ originalSourceRef, baseCanonicalRef, lastCommittedMarkdownRef },
(source) => {
const check = reopen(source)
try {
return check.getMarkdown()
} finally {
check.destroy()
}
}
)
expect(saved.didSerialize).toBe(true)
expect(saved.markdown).toContain('a<br>1')
const reopened = reopen(saved.markdown)
try {
reopened.state.doc.check()
const rendered = document.createElement('div')
rendered.innerHTML = reopened.getHTML()
expect(rendered.querySelector('td')?.textContent).toBe('a1')
expect(rendered.querySelectorAll('td br')).toHaveLength(1)
expect(reopened.getText()).toContain('b1')
expect(reopened.getText()).toContain('a2')
} finally {
reopened.destroy()
}
} finally {
editor.destroy()
element.remove()
}
}
)
it('dismisses the slash menu on Escape even when search has no matches', () => {
const editor = createEditor({
type: 'doc',
@@ -186,7 +186,7 @@ export function createRichMarkdownKeyHandler(
!ctx.slashMenuRef.current &&
!ctx.docLinkMenuRef.current &&
!isComposingMarkdownInput(event, ed) &&
handleRichMarkdownTableEnter(ed)
handleRichMarkdownTableEnter(ed, event, mod)
) {
event.preventDefault()
return true
@@ -1,6 +1,12 @@
import type { Editor } from '@tiptap/react'
import { TextSelection } from '@tiptap/pm/state'
import { isInTable, moveCellForward, nextCell, selectionCell } from '@tiptap/pm/tables'
import {
CellSelection,
isInTable,
moveCellForward,
nextCell,
selectionCell
} from '@tiptap/pm/tables'
function moveToVerticalNeighbor(editor: Editor, direction: 1 | -1): boolean {
const { state, view } = editor
@@ -17,9 +23,21 @@ function moveToVerticalNeighbor(editor: Editor, direction: 1 | -1): boolean {
return true
}
// Tiptap HardBreak binds exactly Shift-Enter and Mod-Enter; a multi-cell selection cannot take a break.
function leavesEnterToHardBreak(event: KeyboardEvent, mod: boolean, editor: Editor): boolean {
if (event.altKey || editor.state.selection instanceof CellSelection) {
return false
}
return event.shiftKey ? !event.ctrlKey && !event.metaKey : mod
}
/** Move down or append a row on Enter; GFM cannot preserve additional in-cell paragraphs. */
export function handleRichMarkdownTableEnter(editor: Editor): boolean {
if (!isInTable(editor.state)) {
export function handleRichMarkdownTableEnter(
editor: Editor,
event?: KeyboardEvent,
mod = false
): boolean {
if (!isInTable(editor.state) || (event && leavesEnterToHardBreak(event, mod, editor))) {
return false
}