diff --git a/apps/web/src/components/ComposerPromptEditorTiptap.ios.browser.test.tsx b/apps/web/src/components/ComposerPromptEditorTiptap.ios.browser.test.tsx new file mode 100644 index 000000000000..84cdebaa59b3 --- /dev/null +++ b/apps/web/src/components/ComposerPromptEditorTiptap.ios.browser.test.tsx @@ -0,0 +1,163 @@ +import type { Editor } from "@tiptap/core"; +import { createRef } from "react"; +import { expect, it, vi } from "vite-plus/test"; + +import { act, fakeTimers, render } from "../../test/render"; +import { serializeEditorDoc } from "~/composer-rich-text-doc"; +import { + ComposerPromptEditorTiptap as ComposerPromptEditor, + type ComposerPromptEditorHandle, +} from "./ComposerPromptEditorTiptap"; + +// ProseMirror selects its iOS Enter fallback when its module is first evaluated. +// A dedicated browser-test iframe keeps this identity out of the desktop tests. +vi.hoisted(() => { + Object.defineProperties(navigator, { + platform: { configurable: true, value: "MacIntel" }, + vendor: { configurable: true, value: "Apple Computer, Inc." }, + maxTouchPoints: { configurable: true, value: 5 }, + userAgent: { + configurable: true, + value: + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.6.1 Safari/605.1.15", + }, + }); + Object.defineProperty(document, "ontouchend", { configurable: true, value: null }); +}); + +it.each([false, true])( + "keeps hardware Shift+Enter out of the iOS unmodified Enter fallback (rich text: %s)", + async (richTextEnabled) => { + const submit = vi.fn(); + const commandKey = vi.fn((_key: string, event: KeyboardEvent) => { + if (event.shiftKey) return false; + submit(); + return true; + }); + await using screen = await render( + ()} + value="line one" + cursor={8} + richTextEnabled={richTextEnabled} + contextRecords={new Map()} + skills={[]} + disabled={false} + placeholder="" + onChange={() => {}} + onPaste={() => {}} + onCommandKeyDown={commandKey} + />, + ); + const element = screen.container.querySelector(".tiptap"); + if (!element) throw new Error("Composer did not mount"); + const editor = element.editor; + await act(async () => { + editor.commands.setTextSelection(5); + editor.view.focus(); + }); + using _timers = fakeTimers(); + await act(async () => { + // Model native text waiting for ProseMirror's deferred observer flush. + ( + editor.view as typeof editor.view & { domObserver: { flushSoon(): void } } + ).domObserver.flushSoon(); + const text = element.querySelector("p")?.firstChild; + if (!(text instanceof Text)) throw new Error("Composer text did not mount"); + const replacement = document.createTextNode("line! one"); + text.replaceWith(replacement); + window.getSelection()?.collapse(replacement, 5); + expect(serializeEditorDoc(editor.state.doc).value).toBe("line one"); + const shiftEnter = new KeyboardEvent("keydown", { + key: "Enter", + code: "Enter", + keyCode: 13, + shiftKey: true, + bubbles: true, + cancelable: true, + }); + element.dispatchEvent(shiftEnter); + // Safari would otherwise insert its own line break on top of the handled one. + expect(shiftEnter.defaultPrevented).toBe(true); + await vi.advanceTimersByTimeAsync(200); + }); + expect(submit).not.toHaveBeenCalled(); + expect(commandKey).toHaveBeenCalledExactlyOnceWith( + "Enter", + expect.objectContaining({ shiftKey: true }), + false, + ); + expect(serializeEditorDoc(editor.state.doc).value).toBe("line!\n one"); + + commandKey.mockClear(); + await act(async () => { + element.dispatchEvent( + new KeyboardEvent("keydown", { + key: "Enter", + code: "Enter", + keyCode: 13, + bubbles: true, + cancelable: true, + }), + ); + await vi.advanceTimersByTimeAsync(200); + }); + expect(submit).toHaveBeenCalledOnce(); + expect(commandKey).toHaveBeenCalledExactlyOnceWith( + "Enter", + expect.objectContaining({ key: "Enter" }), + false, + ); + expect(commandKey.mock.calls[0]?.[1].shiftKey).toBeFalsy(); + expect(serializeEditorDoc(editor.state.doc).value).toBe("line!\n one"); + }, +); + +it.each([false, true])( + "leaves a disabled draft unchanged on hardware Shift+Enter (rich text: %s)", + async (richTextEnabled) => { + const commandKey = vi.fn(() => false); + const editorRef = createRef(); + const composer = (disabled: boolean) => ( + {}} + onPaste={() => {}} + onCommandKeyDown={commandKey} + /> + ); + await using screen = await render(composer(false)); + const element = screen.container.querySelector(".tiptap"); + if (!element) throw new Error("Composer did not mount"); + const editor = element.editor; + await act(async () => { + editor.commands.setTextSelection(9); + editor.view.focus(); + }); + await screen.rerender(composer(true)); + expect(editor.view.editable).toBe(false); + using _timers = fakeTimers(); + await act(async () => { + element.dispatchEvent( + new KeyboardEvent("keydown", { + key: "Enter", + code: "Enter", + keyCode: 13, + shiftKey: true, + bubbles: true, + cancelable: true, + }), + ); + await vi.advanceTimersByTimeAsync(200); + }); + expect(commandKey).not.toHaveBeenCalled(); + expect(serializeEditorDoc(editor.state.doc).value).toBe("line one"); + }, +); diff --git a/apps/web/src/components/ComposerPromptEditorTiptap.tsx b/apps/web/src/components/ComposerPromptEditorTiptap.tsx index 5a036e34611e..53a9393c3590 100644 --- a/apps/web/src/components/ComposerPromptEditorTiptap.tsx +++ b/apps/web/src/components/ComposerPromptEditorTiptap.tsx @@ -6,6 +6,7 @@ import { Extension, findChildren, InputRule, + isiOS, Node, wrappingInputRule, type JSONContent, @@ -705,6 +706,21 @@ const headingInputRule = new InputRule({ }, }); +function isHardwareShiftEnterOnIOS(view: EditorView, event: KeyboardEvent): boolean { + return ( + isiOS() && + view.editable && + event.key === "Enter" && + event.shiftKey && + !event.altKey && + !event.ctrlKey && + !event.metaKey && + !view.composing && + !event.isComposing && + event.keyCode !== 229 + ); +} + function isInCodeBlock(view: EditorView): boolean { return view.state.selection.$from.parent.type.spec.code === true; } @@ -1115,16 +1131,24 @@ function ComposerPromptEditorTiptapInner( editorProps: { attributes: editorAttributes, handleDOMEvents: { - // Composer commands keep precedence; otherwise true skips the editor's keymaps without - // preventDefault, so a bound app command still runs. - keydown: (_view, event) => { - if (!isRichTextFormattingShortcut(event)) return false; - if (readCallbacks().onCommandKeyDown?.(event.key, event)) { - event.preventDefault(); - event.stopPropagation(); - return true; + keydown: (view, event) => { + if (isRichTextFormattingShortcut(event)) { + // Composer commands keep precedence; otherwise true skips the editor's keymaps + // without preventDefault, so a bound app command still runs. + if (readCallbacks().onCommandKeyDown?.(event.key, event)) { + event.preventDefault(); + event.stopPropagation(); + return true; + } + return hasCommandForKeyDown(event); } - return hasCommandForKeyDown(event); + if (!isHardwareShiftEnterOnIOS(view, event)) return false; + // ProseMirror's iOS Enter fallback replays Enter without Shift, so run handleKeyDown + // now, flushing pending DOM edits first as ProseMirror's own keydown path does. + ( + view as typeof view & { domObserver: { forceFlush(): void } } + ).domObserver.forceFlush(); + return view.someProp("handleKeyDown", (handler) => handler(view, event)) ?? false; }, }, handleKeyDown: (view, event) => {