Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 57 additions & 0 deletions packages/core/src/extensions/code-block-enter-guard.ts
Original file line number Diff line number Diff line change
@@ -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 `<pre>`, and the node view wrapper
* DOM defeats prosemirror-view's DOM-change repair: a rogue `<br>` 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)
}
2 changes: 2 additions & 0 deletions packages/core/src/extensions/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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'] })),
}),
Expand Down
10 changes: 10 additions & 0 deletions packages/core/src/utils/browser.ts
Original file line number Diff line number Diff line change
@@ -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')
72 changes: 72 additions & 0 deletions packages/react/src/components/code-block-enter.test.tsx
Original file line number Diff line number Diff line change
@@ -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<EditorHandle>()
await render(<ProseKitEditor ref={ref} initialMarkdown={CODE_BLOCK_MD} />)
// 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()
})
})
Loading