diff --git a/packages/core/src/extensions/code-block-enter-guard.ts b/packages/core/src/extensions/code-block-enter-guard.ts new file mode 100644 index 00000000..ed4887e6 --- /dev/null +++ b/packages/core/src/extensions/code-block-enter-guard.ts @@ -0,0 +1,57 @@ +import { definePlugin, Priority, withPriority, type PlainExtension } from '@prosekit/core' +import { Plugin } from '@prosekit/pm/state' +import type { EditorView } from '@prosekit/pm/view' + +import { isWebKit } from '../utils/browser.ts' + +/** + * Convert WebKit's native Enter inside a code block back into the keymap + * pipeline. prosemirror-view deliberately skips `preventDefault` on some + * Safari Enter keydowns: every plain Enter on iOS, and on desktop the first + * keydown within 500ms after `compositionend` (WebKit fires `compositionend` + * before the keydown that commits an IME composition, so that keydown is + * swallowed as a likely IME confirmation). The browser's native + * `insertParagraph` then clone-splits the `
`, and the node view wrapper
+ * DOM defeats prosemirror-view's DOM-change repair: a rogue `
` stays + * behind, or the text before the caret is silently deleted. ProseMirror + * cancels every Enter keydown a command handles, so an uncanceled native + * `insertParagraph` or `insertLineBreak` in a code block is always such a + * leak; cancel it and run the key through the handlers instead. + */ +export function defineCodeBlockEnterGuard(): PlainExtension { + const plugin = new Plugin({ + props: { + handleDOMEvents: { + beforeinput: (view, event) => { + if (!isWebKit || !event.cancelable) return false + if (event.inputType !== 'insertParagraph' && event.inputType !== 'insertLineBreak') { + return false + } + const { $from } = view.state.selection + if (!$from.parent.type.spec.code) return false + + event.preventDefault() + + // After an iOS Enter keydown, prosemirror-view schedules a synthetic + // Enter 200ms later as a fallback for the native edit this guard just + // canceled (`lastIOSEnterFallbackTimeout` in + // https://github.com/ProseMirror/prosemirror-view/blob/1.42.2/src/input.ts#L123-L131); + // clear the flag so the fallback cannot insert a second newline. + const input = (view as EditorView & { input?: { lastIOSEnter: number } }).input + if (input != null) input.lastIOSEnter = 0 + + const keydown = new KeyboardEvent('keydown', { + key: 'Enter', + code: 'Enter', + keyCode: 13, + shiftKey: event.inputType === 'insertLineBreak', + }) + view.someProp('handleKeyDown', (handler) => handler(view, keydown)) + return true + }, + }, + }, + }) + + return withPriority(definePlugin(plugin), Priority.highest) +} diff --git a/packages/core/src/extensions/extension.ts b/packages/core/src/extensions/extension.ts index f7be5a78..8b354753 100644 --- a/packages/core/src/extensions/extension.ts +++ b/packages/core/src/extensions/extension.ts @@ -14,6 +14,7 @@ import { defineVirtualSelection } from '@prosekit/extensions/virtual-selection' import { defineAtomMarkNavigation } from './atom-mark-navigation.ts' import { defineClipboard } from './clipboard/clipboard.ts' +import { defineCodeBlockEnterGuard } from './code-block-enter-guard.ts' import { defineCodeBlockSyntaxHighlight } from './code-block-highlight.ts' import { defineCodeBlock } from './code-block.ts' import { defineEditorCommands } from './commands.ts' @@ -81,6 +82,7 @@ function defineEditorExtensionImpl(options: EditorExtensionOptions) { defineScrollToSelection(), defineHiddenRunCaret(), defineSystemSubstitutionGuard(), + defineCodeBlockEnterGuard(), defineAtomMarkNavigation({ marks: ATOM_SOURCE_MARK_NAMES.map((name) => ({ name, modes: ['hide', 'focus', 'show'] })), }), diff --git a/packages/core/src/utils/browser.ts b/packages/core/src/utils/browser.ts new file mode 100644 index 00000000..b80ed62e --- /dev/null +++ b/packages/core/src/utils/browser.ts @@ -0,0 +1,10 @@ +// TODO: import `isWebKit` from `@prosekit/core` once a released version ships +// it, and delete this file. +// +// Apple's WebKit engine: Safari on any platform, every iOS browser, and +// WKWebView hosts. Blink reports a "Google Inc." vendor, and Node's global +// `navigator` has no `vendor` at all. +export const isWebKit: boolean = + typeof navigator !== 'undefined' && + navigator.vendor != null && + navigator.vendor.includes('Apple Computer') diff --git a/packages/react/src/components/code-block-enter.test.tsx b/packages/react/src/components/code-block-enter.test.tsx new file mode 100644 index 00000000..a3e89ea0 --- /dev/null +++ b/packages/react/src/components/code-block-enter.test.tsx @@ -0,0 +1,72 @@ +import '../testing/index.ts' + +import { TextSelection } from '@prosekit/pm/state' +import type { EditorView } from '@prosekit/pm/view' +import { createRef } from 'react' +import { describe, expect, it, vi } from 'vitest' +import { render } from 'vitest-browser-react' +import { page, userEvent } from 'vitest/browser' + +import { ProseKitEditor } from './prosekit-editor.tsx' +import type { EditorHandle } from './types.ts' + +const pmRoot = page.locate('.ProseMirror') +const tokens = pmRoot.locate('pre code [class*="tok-"]') +const rogueBreak = pmRoot.locate('br:not(.ProseMirror-trailingBreak)') + +const CODE_BLOCK_MD = '```js\nfoobar\n```' + +describe('enter after a composition commit', () => { + async function setupCodeBlockEditor() { + const ref = createRef() + await render() + // WebKit's clone-split only takes its production shape once highlight + // token spans wrap the code text. + await expect.element(tokens.first(), { timeout: 15000 }).toBeInTheDocument() + const view = ref.current?.editor?.view + if (!view) throw new Error('editor not mounted') + return { ref, view } + } + + function placeCaret(view: EditorView, position: number) { + view.dispatch(view.state.tr.setSelection(TextSelection.create(view.state.doc, position))) + view.focus() + } + + // prosemirror-view swallows the first Safari keydown within 500ms after + // `compositionend` without `preventDefault`, handing the key to the + // browser's native editing (WebKit fires `compositionend` before the keydown + // that commits an IME composition, so that keydown reads as a likely IME + // confirmation). No automation driver can run a real IME, so stamp the + // timestamp prosemirror-view records from that `compositionend`; the Enter + // pressed afterwards stays a real key whose native default action runs. + function armPostCompositionWindow(view: EditorView) { + const input = (view as EditorView & { input?: { compositionEndedAt: number } }).input + if (input == null) throw new Error('prosemirror-view no longer exposes view.input') + input.compositionEndedAt = Date.now() + } + + it('keeps one pre and no rogue br at the start of the code text', async () => { + const { ref, view } = await setupCodeBlockEditor() + placeCaret(view, 1) + armPostCompositionWindow(view) + await userEvent.keyboard('{Enter}') + await vi.waitFor(() => { + expect(ref.current?.getMarkdown()).toContain('```js\n\nfoobar\n```') + }) + expect(pmRoot.locate('pre').all()).toHaveLength(1) + await expect.element(rogueBreak).not.toBeInTheDocument() + }) + + it('keeps the text before the caret in the middle of the code text', async () => { + const { ref, view } = await setupCodeBlockEditor() + placeCaret(view, 4) + armPostCompositionWindow(view) + await userEvent.keyboard('{Enter}') + await vi.waitFor(() => { + expect(ref.current?.getMarkdown()).toContain('```js\nfoo\nbar\n```') + }) + expect(pmRoot.locate('pre').all()).toHaveLength(1) + await expect.element(rogueBreak).not.toBeInTheDocument() + }) +})