diff --git a/src/renderer/src/components/editor/rich-markdown-key-handler.test.ts b/src/renderer/src/components/editor/rich-markdown-key-handler.test.ts index 1c60fabe9c2..a97708241cf 100644 --- a/src/renderer/src/components/editor/rich-markdown-key-handler.test.ts +++ b/src/renderer/src/components/editor/rich-markdown-key-handler.test.ts @@ -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
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', diff --git a/src/renderer/src/components/editor/rich-markdown-key-handler.ts b/src/renderer/src/components/editor/rich-markdown-key-handler.ts index bcc330ff683..d22fd3157ef 100644 --- a/src/renderer/src/components/editor/rich-markdown-key-handler.ts +++ b/src/renderer/src/components/editor/rich-markdown-key-handler.ts @@ -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 diff --git a/src/renderer/src/components/editor/rich-markdown-table-enter.ts b/src/renderer/src/components/editor/rich-markdown-table-enter.ts index a1a2903f76c..a329c554b06 100644 --- a/src/renderer/src/components/editor/rich-markdown-table-enter.ts +++ b/src/renderer/src/components/editor/rich-markdown-table-enter.ts @@ -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 }