mirror of
https://github.com/stablyai/orca.git
synced 2026-11-04 19:17:28 +00:00
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:
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user