From 05996a2c1c90e9466627515019d95bbcb9f0b6fb Mon Sep 17 00:00:00 2001 From: Maier Stefan Date: Thu, 22 Oct 2026 09:30:36 +0200 Subject: [PATCH 1/3] fix(web): preserve Shift+Enter on iPad hardware keyboards --- ...serPromptEditorTiptap.ios.browser.test.tsx | 120 ++++++++++++++++++ .../components/ComposerPromptEditorTiptap.tsx | 20 +++ 2 files changed, 140 insertions(+) create mode 100644 apps/web/src/components/ComposerPromptEditorTiptap.ios.browser.test.tsx 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..f0495ed91164 --- /dev/null +++ b/apps/web/src/components/ComposerPromptEditorTiptap.ios.browser.test.tsx @@ -0,0 +1,120 @@ +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 { ComposerPromptEditor, type ComposerPromptEditorHandle } from "./ComposerPromptEditor"; + +// 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(9); + editor.view.focus(); + }); + 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(submit).not.toHaveBeenCalled(); + expect(commandKey).toHaveBeenCalledExactlyOnceWith( + "Enter", + expect.objectContaining({ shiftKey: true }), + false, + ); + expect(serializeEditorDoc(editor.state.doc).value).toBe("line one\n"); + + 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 one\n"); + + commandKey.mockClear(); + submit.mockClear(); + await act(async () => { + element.dispatchEvent(new CompositionEvent("compositionstart", { bubbles: true })); + 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(submit).not.toHaveBeenCalled(); + expect(serializeEditorDoc(editor.state.doc).value).toBe("line one\n"); + }, +); diff --git a/apps/web/src/components/ComposerPromptEditorTiptap.tsx b/apps/web/src/components/ComposerPromptEditorTiptap.tsx index 824d061496b9..1520ae8ea799 100644 --- a/apps/web/src/components/ComposerPromptEditorTiptap.tsx +++ b/apps/web/src/components/ComposerPromptEditorTiptap.tsx @@ -4,6 +4,7 @@ import { Extension, findChildren, InputRule, + isiOS, Node, wrappingInputRule, type JSONContent, @@ -1160,6 +1161,25 @@ function ComposerPromptEditorTiptapInner( editable: !disabled, editorProps: { attributes: editorAttributes, + handleDOMEvents: { + keydown: (view, event) => { + if ( + !isiOS() || + event.key !== "Enter" || + !event.shiftKey || + event.altKey || + event.ctrlKey || + event.metaKey || + view.composing || + event.isComposing || + event.keyCode === 229 + ) + return false; + // ProseMirror's iOS Enter fallback synthesizes an unmodified keydown. + // Hardware Shift+Enter must reach the composer before Shift is lost. + return view.someProp("handleKeyDown", (handler) => handler(view, event)) ?? false; + }, + }, handleKeyDown: (view, event) => { // In a right-to-left line the start is on the right. const backArrow = caretLineDirection(view.state) === "rtl" ? "ArrowRight" : "ArrowLeft"; From fa312ab79ac551fd465b87fa7abfce59487ad311 Mon Sep 17 00:00:00 2001 From: Maier Stefan Date: Thu, 22 Oct 2026 10:49:23 +0200 Subject: [PATCH 2/3] fix(web): preserve editor safeguards for iOS Shift+Enter --- ...serPromptEditorTiptap.ios.browser.test.tsx | 67 +++++++++++++++++-- .../components/ComposerPromptEditorTiptap.tsx | 6 ++ 2 files changed, 69 insertions(+), 4 deletions(-) diff --git a/apps/web/src/components/ComposerPromptEditorTiptap.ios.browser.test.tsx b/apps/web/src/components/ComposerPromptEditorTiptap.ios.browser.test.tsx index f0495ed91164..2d5b3cd5a0ff 100644 --- a/apps/web/src/components/ComposerPromptEditorTiptap.ios.browser.test.tsx +++ b/apps/web/src/components/ComposerPromptEditorTiptap.ios.browser.test.tsx @@ -50,11 +50,21 @@ it.each([false, true])( if (!element) throw new Error("Composer did not mount"); const editor = element.editor; await act(async () => { - editor.commands.setTextSelection(9); + 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"); element.dispatchEvent( new KeyboardEvent("keydown", { key: "Enter", @@ -73,7 +83,7 @@ it.each([false, true])( expect.objectContaining({ shiftKey: true }), false, ); - expect(serializeEditorDoc(editor.state.doc).value).toBe("line one\n"); + expect(serializeEditorDoc(editor.state.doc).value).toBe("line!\n one"); commandKey.mockClear(); await act(async () => { @@ -95,7 +105,7 @@ it.each([false, true])( false, ); expect(commandKey.mock.calls[0]?.[1].shiftKey).toBeFalsy(); - expect(serializeEditorDoc(editor.state.doc).value).toBe("line one\n"); + expect(serializeEditorDoc(editor.state.doc).value).toBe("line!\n one"); commandKey.mockClear(); submit.mockClear(); @@ -115,6 +125,55 @@ it.each([false, true])( }); expect(commandKey).not.toHaveBeenCalled(); expect(submit).not.toHaveBeenCalled(); - expect(serializeEditorDoc(editor.state.doc).value).toBe("line one\n"); + 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 1520ae8ea799..e1376ec6d0ae 100644 --- a/apps/web/src/components/ComposerPromptEditorTiptap.tsx +++ b/apps/web/src/components/ComposerPromptEditorTiptap.tsx @@ -1165,6 +1165,7 @@ function ComposerPromptEditorTiptapInner( keydown: (view, event) => { if ( !isiOS() || + !view.editable || event.key !== "Enter" || !event.shiftKey || event.altKey || @@ -1177,6 +1178,11 @@ function ComposerPromptEditorTiptapInner( return false; // ProseMirror's iOS Enter fallback synthesizes an unmodified keydown. // Hardware Shift+Enter must reach the composer before Shift is lost. + // Preserve the normal keydown path's pending-DOM safeguard. + // ProseMirror exposes its observer only as an internal property. + ( + view as typeof view & { domObserver: { forceFlush(): void } } + ).domObserver.forceFlush(); return view.someProp("handleKeyDown", (handler) => handler(view, event)) ?? false; }, }, From 1d74727e107507055ee8eba32cbe23d0bbcc6e0c Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 14 Nov 2026 00:37:38 +0000 Subject: [PATCH 3/3] test(web): assert iPad Shift+Enter suppresses the native newline Drop the composition sub-case, which only restated a guard, and check that the handled Shift+Enter prevents Safari's own line break. Co-Authored-By: Claude Opus 5.5 --- ...serPromptEditorTiptap.ios.browser.test.tsx | 41 +++++-------------- 1 file changed, 11 insertions(+), 30 deletions(-) diff --git a/apps/web/src/components/ComposerPromptEditorTiptap.ios.browser.test.tsx b/apps/web/src/components/ComposerPromptEditorTiptap.ios.browser.test.tsx index 0e8c51925a9d..84cdebaa59b3 100644 --- a/apps/web/src/components/ComposerPromptEditorTiptap.ios.browser.test.tsx +++ b/apps/web/src/components/ComposerPromptEditorTiptap.ios.browser.test.tsx @@ -68,16 +68,17 @@ it.each([false, true])( text.replaceWith(replacement); window.getSelection()?.collapse(replacement, 5); expect(serializeEditorDoc(editor.state.doc).value).toBe("line one"); - element.dispatchEvent( - new KeyboardEvent("keydown", { - key: "Enter", - code: "Enter", - keyCode: 13, - shiftKey: true, - bubbles: true, - cancelable: true, - }), - ); + 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(); @@ -109,26 +110,6 @@ it.each([false, true])( ); expect(commandKey.mock.calls[0]?.[1].shiftKey).toBeFalsy(); expect(serializeEditorDoc(editor.state.doc).value).toBe("line!\n one"); - - commandKey.mockClear(); - submit.mockClear(); - await act(async () => { - element.dispatchEvent(new CompositionEvent("compositionstart", { bubbles: true })); - 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(submit).not.toHaveBeenCalled(); - expect(serializeEditorDoc(editor.state.doc).value).toBe("line!\n one"); }, );