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..b40408de --- /dev/null +++ b/packages/react/src/components/code-block-enter.test.tsx @@ -0,0 +1,61 @@ +import '../testing/index.ts' + +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 codeText = pmRoot.locate('pre code') +const tokens = codeText.locate('[class*="tok-"]') +const rogueBreak = pmRoot.locate('br:not(.ProseMirror-trailingBreak)') + +const CODE_BLOCK_MD = '```js\nfoobar\n```' + +// In the `react-webkit-ios` project this file runs with an iPhone Safari user +// agent, where prosemirror-view hands every plain Enter in a code block to +// WebKit's native editing; in the regular project the same gestures pin the +// desktop behavior. Every step is a user gesture: click, arrow keys, Enter. +describe('enter inside a code block', () => { + async function setupCodeBlockEditor() { + const ref = createRef() + await render() + // The corruption only takes its production shape once highlight token + // spans wrap the code text. + await expect.element(tokens.first(), { timeout: 15000 }).toBeInTheDocument() + return ref + } + + // Click into the code text, then walk left to the line start: six presses + // cover every caret position `foobar` allows. + async function moveCaretToCodeStart() { + await codeText.click() + await userEvent.keyboard('{ArrowLeft}'.repeat(6)) + } + + it('inserts a newline at the start of the code text', async () => { + const ref = await setupCodeBlockEditor() + await moveCaretToCodeStart() + 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 when pressing enter mid-line', async () => { + const ref = await setupCodeBlockEditor() + await moveCaretToCodeStart() + await userEvent.keyboard('{ArrowRight}'.repeat(3)) + 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() + }) +}) diff --git a/packages/react/vitest.ios.config.ts b/packages/react/vitest.ios.config.ts new file mode 100644 index 00000000..0c959cda --- /dev/null +++ b/packages/react/vitest.ios.config.ts @@ -0,0 +1,23 @@ +import { defineSharedConfig } from '@meowdown/vitest/config' +import { defineProject, mergeConfig } from 'vitest/config' + +// Runs the code block Enter test as an iPhone Safari user. Active only in the +// WebKit run; other runs keep the project empty so no second browser starts. +const enabled = process.env.MEOWDOWN_TEST_BROWSER === 'webkit' + +export default enabled + ? mergeConfig( + defineSharedConfig({ env: 'browser', groupOrder: 2100, emulateIPhone: true }), + defineProject({ + test: { + name: 'react-webkit-ios', + include: ['src/components/code-block-enter.test.tsx'], + }, + }), + ) + : defineProject({ + test: { + name: 'react-webkit-ios', + include: [], + }, + }) diff --git a/packages/vitest/src/config.ts b/packages/vitest/src/config.ts index bac23aff..9ed64289 100644 --- a/packages/vitest/src/config.ts +++ b/packages/vitest/src/config.ts @@ -5,12 +5,20 @@ import { defineProject } from 'vitest/config' const IS_BOT = !!(process.env.AI_AGENT || process.env.CI) const IS_DEBUG = !!process.env.DEBUG +// An iPhone Safari user agent. prosemirror-view detects iOS from the `Mobile/` +// token alone and hands every plain Enter in that mode to WebKit's native +// editing instead of the keymap. +const IPHONE_USER_AGENT = + 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2 Mobile/15E148 Safari/604.1' + export function defineSharedConfig({ groupOrder, env, + emulateIPhone = false, }: { groupOrder: number env: 'browser' | 'node' + emulateIPhone?: boolean }) { const browserName = (() => { if (process.env.MEOWDOWN_TEST_BROWSER === 'webkit') { @@ -60,6 +68,7 @@ export function defineSharedConfig({ channel: browserName === 'chromium' ? 'chromium' : undefined, }, contextOptions: { + ...(emulateIPhone ? { userAgent: IPHONE_USER_AGENT } : {}), reducedMotion: 'reduce', hasTouch: true, // A list of permissions to grant to all pages in this context. diff --git a/vitest.config.ts b/vitest.config.ts index e22b13f1..aae88f77 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -12,6 +12,6 @@ export default defineConfig({ include: ['packages/*/src/**'], }, slowTestThreshold: 10_000, - projects: ['./packages/*'], + projects: ['./packages/*', './packages/react/vitest.ios.config.ts'], }, })