Skip to content

Commit a4d3e04

Browse files
authored
Merge pull request #8346 from ueberdosis/codex/backport-v3-mobile-enter
fix(react): backport mobile Enter handling to v3
2 parents de4369d + a549513 commit a4d3e04

10 files changed

Lines changed: 460 additions & 8 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@tiptap/react': patch
3+
---
4+
5+
Fix Enter and Shift-Enter inside React node views on iOS and Android.

AGENTS.md

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -50,12 +50,12 @@ Prefer simple, readable code over clever code. Use early returns. Avoid deep nes
5050

5151
```ts
5252
// good
53-
items.map((item, index) => createNode(item, index));
54-
decorations.filter((decoration) => decoration.visible);
53+
items.map((item, index) => createNode(item, index))
54+
decorations.filter(decoration => decoration.visible)
5555

5656
// bad
57-
items.map((i, idx) => createNode(i, idx));
58-
decorations.filter((d) => d.visible);
57+
items.map((i, idx) => createNode(i, idx))
58+
decorations.filter(d => d.visible)
5959
```
6060

6161
### Comments
@@ -148,7 +148,7 @@ Run `pnpm changeset`, or write the file yourself as `.changeset/YYYY-MM-DD-short
148148

149149
```markdown
150150
---
151-
"@tiptap/core": patch
151+
'@tiptap/core': patch
152152
---
153153

154154
One short sentence on what changed for the user.

demos/src/GuideNodeViews/ReactComponentContent/React/index.jsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ export default () => {
1313
<p>
1414
This is still the text editor you’re used to, but enriched with node views.
1515
</p>
16-
<react-component>This is editable. You can create a new component by pressing Mod+Enter.</react-component>
16+
<react-component>This is editable. Press Enter to split the content, Shift+Enter for a line break, or Mod+Enter for a new component.</react-component>
1717
<p>
1818
Did you see that? That’s a React component. We are really living in the future.
1919
</p>

demos/src/GuideNodeViews/ReactComponentContent/index.spec.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ test.describe('GuideNodeViews/ReactComponentContent/React', () => {
99

1010
test('renders the initial NodeView content', async ({ page }) => {
1111
await expect(page.locator('.tiptap .react-component .content')).toHaveText(
12-
'This is editable. You can create a new component by pressing Mod+Enter.',
12+
'This is editable. Press Enter to split the content, Shift+Enter for a line break, or Mod+Enter for a new component.',
1313
)
1414
})
1515
})

packages/react/package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,12 +56,14 @@
5656
"use-sync-external-store": "^1.4.0"
5757
},
5858
"devDependencies": {
59+
"@testing-library/react": "16.2.0",
5960
"@tiptap/core": "workspace:^",
6061
"@tiptap/pm": "workspace:^",
6162
"@types/react": "^19.0.0",
6263
"@types/react-dom": "^19.0.0",
6364
"react": "^19.0.0",
64-
"react-dom": "^19.0.0"
65+
"react-dom": "^19.0.0",
66+
"vite-plus": "catalog:"
6567
},
6668
"peerDependencies": {
6769
"@tiptap/core": "workspace:*",

packages/react/src/EditorContent.tsx

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,16 @@
11
import type { Editor } from '@tiptap/core'
2+
import { Plugin, PluginKey } from '@tiptap/pm/state'
23
import type { ForwardedRef, HTMLProps, LegacyRef, MutableRefObject } from 'react'
34
import React, { forwardRef } from 'react'
45
import ReactDOM from 'react-dom'
56
import { useSyncExternalStore } from 'use-sync-external-store/shim/index.js'
67

78
import type { ContentComponent, EditorWithContentComponent } from './Editor.js'
9+
import { handleMobileEnter } from './handleMobileEnter.js'
810
import type { ReactRenderer } from './ReactRenderer.js'
911

12+
const mobileEnterPluginKey = new PluginKey('reactMobileEnter')
13+
1014
const mergeRefs = <T extends HTMLDivElement>(
1115
...refs: Array<MutableRefObject<T> | LegacyRef<T> | undefined>
1216
) => {
@@ -138,6 +142,17 @@ export class PureEditorContent extends React.Component<
138142

139143
editor.createNodeViews()
140144

145+
editor.registerPlugin(
146+
new Plugin({
147+
key: mobileEnterPluginKey,
148+
props: {
149+
handleDOMEvents: {
150+
beforeinput: (_view, event) => handleMobileEnter(editor, event),
151+
},
152+
},
153+
}),
154+
)
155+
141156
editor.isEditorContentInitialized = true
142157

143158
this.forceUpdate()
@@ -154,6 +169,7 @@ export class PureEditorContent extends React.Component<
154169
editor.isEditorContentInitialized = false
155170

156171
if (!editor.isDestroyed) {
172+
editor.unregisterPlugin(mobileEnterPluginKey)
157173
editor.view.setProps({
158174
nodeViews: {},
159175
})
Lines changed: 218 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,218 @@
1+
import { Editor, type EditorOptions } from '@tiptap/core'
2+
import Document from '@tiptap/extension-document'
3+
import HardBreak from '@tiptap/extension-hard-break'
4+
import Paragraph from '@tiptap/extension-paragraph'
5+
import Text from '@tiptap/extension-text'
6+
import { afterEach, describe, expect, it, vi } from 'vite-plus/test'
7+
8+
import { handleMobileEnter } from './handleMobileEnter.js'
9+
10+
vi.hoisted(() => {
11+
Object.defineProperty(navigator, 'userAgent', {
12+
configurable: true,
13+
value: 'Mozilla/5.0 (Linux; Android 14) Chrome/140.0.0.0',
14+
})
15+
})
16+
17+
describe('handleMobileEnter', () => {
18+
let editor: Editor
19+
20+
function createEditor(options: Partial<EditorOptions> = {}) {
21+
vi.stubGlobal('navigator', { platform: 'Android', userAgent: 'Android 14' })
22+
editor = new Editor({
23+
extensions: [
24+
Document,
25+
Paragraph.extend({
26+
addNodeView() {
27+
return () => {
28+
const dom = document.createElement('p')
29+
30+
dom.dataset.nodeViewContentReact = ''
31+
return {
32+
dom,
33+
contentDOM: dom,
34+
ignoreMutation: mutation => mutation.type === 'attributes',
35+
}
36+
}
37+
},
38+
}),
39+
Text,
40+
HardBreak,
41+
],
42+
content: '<p>Hello</p>',
43+
...options,
44+
})
45+
document.body.append(editor.view.dom)
46+
const contentDOM = editor.view.dom.firstElementChild as HTMLElement
47+
48+
editor.commands.setTextSelection(6)
49+
editor.view.focus()
50+
51+
return contentDOM
52+
}
53+
54+
function beforeInput(options: InputEventInit = {}) {
55+
const event = new InputEvent('beforeinput', {
56+
inputType: 'insertParagraph',
57+
cancelable: true,
58+
...options,
59+
})
60+
61+
handleMobileEnter(editor, event)
62+
return event
63+
}
64+
65+
afterEach(() => {
66+
editor?.destroy()
67+
document.body.replaceChildren()
68+
vi.restoreAllMocks()
69+
vi.unstubAllGlobals()
70+
})
71+
72+
function queueSelectionChange(contentDOM: HTMLElement) {
73+
const view = editor.view as typeof editor.view & {
74+
domObserver: { flush: () => void; onSelectionChange: () => void }
75+
}
76+
77+
view.domObserver.flush()
78+
// Keep happy-dom's synchronous selectionchange pending until beforeinput.
79+
document.removeEventListener('selectionchange', view.domObserver.onSelectionChange)
80+
document.getSelection()!.collapse(contentDOM.firstChild!, 3)
81+
view.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', keyCode: 13 }))
82+
}
83+
84+
it.each([
85+
['insertParagraph', '<p>Hel</p><p>lo</p>', 6],
86+
['insertLineBreak', '<p>Hel<br>lo</p>', 5],
87+
])('handles %s once at the pending DOM caret', (inputType, html, position) => {
88+
const contentDOM = createEditor()
89+
90+
queueSelectionChange(contentDOM)
91+
92+
expect(editor.state.selection.from).toBe(6)
93+
expect(beforeInput({ inputType }).defaultPrevented).toBe(true)
94+
expect(editor.getHTML()).toBe(html)
95+
expect(editor.state.selection.from).toBe(position)
96+
})
97+
98+
it('calls custom Enter handlers once with the updated selection', () => {
99+
const handleKeyDown = vi.fn(view => {
100+
expect(view.state.selection.from).toBe(4)
101+
return true
102+
})
103+
const contentDOM = createEditor({ editorProps: { handleKeyDown } })
104+
105+
queueSelectionChange(contentDOM)
106+
107+
expect(beforeInput().defaultPrevented).toBe(true)
108+
expect(handleKeyDown).toHaveBeenCalledTimes(1)
109+
})
110+
111+
it('preserves native Enter detection when no handler accepts it', () => {
112+
const contentDOM = createEditor({ enableCoreExtensions: { keymap: false } })
113+
const view = editor.view as typeof editor.view & { input: { lastKeyCode: number | null } }
114+
115+
queueSelectionChange(contentDOM)
116+
117+
expect(beforeInput().defaultPrevented).toBe(false)
118+
expect(view.input.lastKeyCode).toBe(13)
119+
expect(editor.state.selection.from).toBe(4)
120+
expect(editor.getHTML()).toBe('<p>Hello</p>')
121+
})
122+
123+
it('commits pending DOM text and selection before splitting', () => {
124+
const contentDOM = createEditor()
125+
const text = contentDOM.firstChild!
126+
127+
text.textContent = 'Hello world'
128+
document.getSelection()!.collapse(text, 11)
129+
130+
expect(beforeInput().defaultPrevented).toBe(true)
131+
expect(editor.getHTML()).toBe('<p>Hello world</p><p></p>')
132+
expect(editor.state.selection.from).toBe(14)
133+
})
134+
135+
it('preserves pending composition text when Enter arrives before compositionend', () => {
136+
const contentDOM = createEditor()
137+
const text = contentDOM.firstChild!
138+
139+
editor.view.dispatchEvent(new CompositionEvent('compositionstart'))
140+
text.textContent = 'Hello world'
141+
document.getSelection()!.collapse(text, 11)
142+
143+
expect(beforeInput({ isComposing: true }).defaultPrevented).toBe(true)
144+
editor.view.dispatchEvent(new CompositionEvent('compositionend', { data: 'world' }))
145+
editor.commands.insertContent('Next')
146+
147+
expect(editor.getHTML()).toBe('<p>Hello world</p><p>Next</p>')
148+
expect(editor.state.selection.from).toBe(18)
149+
})
150+
151+
it.each([
152+
['insertParagraph', false],
153+
['insertLineBreak', true],
154+
])('uses custom keyboard handlers for %s', (inputType, shiftKey) => {
155+
const handleKeyDown = vi.fn(() => true)
156+
157+
createEditor({ editorProps: { handleKeyDown } })
158+
159+
expect(beforeInput({ inputType }).defaultPrevented).toBe(true)
160+
expect(handleKeyDown).toHaveBeenCalledWith(
161+
editor.view,
162+
expect.objectContaining({ key: 'Enter', keyCode: 13, shiftKey }),
163+
)
164+
expect(editor.getHTML()).toBe('<p>Hello</p>')
165+
})
166+
167+
it('allows native input when no keyboard handler accepts Enter', () => {
168+
createEditor({ enableCoreExtensions: { keymap: false } })
169+
170+
expect(beforeInput().defaultPrevented).toBe(false)
171+
expect(editor.getHTML()).toBe('<p>Hello</p>')
172+
})
173+
174+
it.each([{ cancelable: false }, { inputType: 'insertText' }])(
175+
'leaves unrelated or noncancelable input alone: %j',
176+
options => {
177+
createEditor()
178+
179+
expect(beforeInput(options).defaultPrevented).toBe(false)
180+
expect(editor.getHTML()).toBe('<p>Hello</p>')
181+
},
182+
)
183+
184+
it('respects an already handled event', () => {
185+
createEditor()
186+
const event = new InputEvent('beforeinput', { inputType: 'insertParagraph', cancelable: true })
187+
188+
event.preventDefault()
189+
handleMobileEnter(editor, event)
190+
191+
expect(editor.getHTML()).toBe('<p>Hello</p>')
192+
})
193+
194+
it('leaves desktop input alone', () => {
195+
createEditor()
196+
vi.stubGlobal('navigator', { platform: 'Linux', userAgent: 'Linux' })
197+
198+
expect(beforeInput().defaultPrevented).toBe(false)
199+
expect(editor.getHTML()).toBe('<p>Hello</p>')
200+
})
201+
202+
it('leaves ordinary paragraphs alone', () => {
203+
const contentDOM = createEditor()
204+
205+
delete contentDOM.dataset.nodeViewContentReact
206+
207+
expect(beforeInput().defaultPrevented).toBe(false)
208+
expect(editor.getHTML()).toBe('<p>Hello</p>')
209+
})
210+
211+
it('leaves read-only editors alone', () => {
212+
createEditor()
213+
editor.setEditable(false)
214+
215+
expect(beforeInput().defaultPrevented).toBe(false)
216+
expect(editor.getHTML()).toBe('<p>Hello</p>')
217+
})
218+
})
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import { type Editor, isAndroid, isiOS } from '@tiptap/core'
2+
import type { EditorView } from '@tiptap/pm/view'
3+
4+
type MobileEditorView = EditorView & {
5+
domObserver: { forceFlush: () => void; flush: () => void }
6+
input: { lastKeyCode: number | null; lastIOSEnter: number; lastIOSEnterFallbackTimeout: number }
7+
}
8+
9+
export function handleMobileEnter(editor: Editor, event: InputEvent): void {
10+
if (
11+
editor.isDestroyed ||
12+
event.defaultPrevented ||
13+
!event.cancelable ||
14+
!editor.isEditable ||
15+
(event.inputType !== 'insertParagraph' && event.inputType !== 'insertLineBreak') ||
16+
(!isiOS() && !isAndroid())
17+
) {
18+
return
19+
}
20+
21+
const view = editor.view as MobileEditorView
22+
const root = view.root as (Document | ShadowRoot) & { getSelection?: () => Selection | null }
23+
const selection = root.getSelection?.() ?? view.dom.ownerDocument.getSelection()
24+
const anchor = selection?.anchorNode
25+
const element = anchor?.nodeType === 1 ? (anchor as Element) : anchor?.parentElement
26+
const contentDOM = element?.closest('[data-node-view-content-react]')
27+
28+
if (!contentDOM || !view.dom.contains(contentDOM)) {
29+
return
30+
}
31+
32+
const lastKeyCode = view.input.lastKeyCode
33+
34+
// Prevent the Android selection flush from handling Enter at the old caret.
35+
if (lastKeyCode === 13) {
36+
view.input.lastKeyCode = null
37+
}
38+
39+
// Commit pending text before the Enter handler reads the selection.
40+
view.domObserver.forceFlush()
41+
view.domObserver.flush()
42+
43+
const keyEvent = new KeyboardEvent('keydown', {
44+
key: 'Enter',
45+
code: 'Enter',
46+
keyCode: 13,
47+
shiftKey: event.inputType === 'insertLineBreak',
48+
bubbles: true,
49+
cancelable: true,
50+
})
51+
52+
if (view.someProp('handleKeyDown', handleKeyDown => handleKeyDown(view, keyEvent))) {
53+
event.preventDefault()
54+
// Prevent the iOS fallback from handling this Enter a second time.
55+
view.input.lastIOSEnter = 0
56+
clearTimeout(view.input.lastIOSEnterFallbackTimeout)
57+
} else if (lastKeyCode === 13) {
58+
view.input.lastKeyCode = lastKeyCode
59+
}
60+
}

0 commit comments

Comments
 (0)