Merge pull request #2685 from makeittech/fix/gh-2558-ios-shift-enter

fix(composer): restore Shift+Enter newline on iOS
This commit is contained in:
Serhii Dziupin
2026-08-06 18:13:29 +03:00
committed by GitHub
@@ -130,6 +130,16 @@ function insertedTextOf(transaction: { changes: { iterChanges: (fn: (fromA: numb
return inserted;
}
/**
* True for keydown events CodeMirror re-dispatches after deferring the real
* one (iOS Enter/Backspace/Delete, Chrome Android Enter): `dispatchKey`
* stamps the replacement event with a `synthetic` expando. These events are
* built from the key name alone, so they carry no modifier keys.
*/
function isDeferredSyntheticEvent(event: KeyboardEvent): boolean {
return Boolean((event as unknown as { synthetic?: boolean }).synthetic);
}
/**
* Compartments are configuration keys, not per-view state, so one set can serve
* every editor. They live at module scope because a kept-alive view outlives
@@ -160,6 +170,14 @@ export const ComposerEditor = React.forwardRef<ComposerEditorHandle, ComposerEdi
const hostRef = React.useRef<HTMLDivElement | null>(null);
const viewRef = React.useRef<EditorView | null>(null);
// The real keydown's shift state for the LAST Enter that reached the
// editor. CodeMirror defers Enter on iOS (and Chrome Android) and
// re-dispatches it as a synthetic keydown built from the key name
// alone, dropping every modifier (see `trackRealEnterShift` and the
// `interceptKeys` handler below); this ref is what lets the deferred
// event still tell Shift+Enter from Enter.
const lastRealEnterShiftRef = React.useRef(false);
// Callbacks reach the CodeMirror extensions through a ref: the view is
// built once and must not be torn down when a handler identity changes,
// which would drop focus mid-typing. When a view store is supplied the
@@ -200,7 +218,15 @@ export const ComposerEditor = React.forwardRef<ComposerEditorHandle, ComposerEdi
}
const interceptKeys: KeyBinding[] = [{
any: (_view, event) => handlersRef.current.onKeyDown?.(event) ?? false,
any: (_view, event) => {
// A deferred Enter lost its modifiers in the re-dispatch;
// give the caller's policy (Enter vs Shift+Enter) back the
// shift state it saw on the real keydown.
if (event.key === 'Enter' && isDeferredSyntheticEvent(event) && lastRealEnterShiftRef.current) {
Object.defineProperty(event, 'shiftKey', { value: true });
}
return handlersRef.current.onKeyDown?.(event) ?? false;
},
}];
const view = new EditorView({
@@ -276,6 +302,25 @@ export const ComposerEditor = React.forwardRef<ComposerEditorHandle, ComposerEdi
viewRef.current = view;
if (store) store.view = view;
// CodeMirror defers Enter on iOS (and Chrome Android): the real
// keydown is captured without running the keymaps, the browser's
// native newline goes through, and the keymaps then run against a
// synthetic keydown `dispatchKey` builds from the key name alone —
// which has NO modifiers. Recording the real shift state here (a
// plain listener, registered after CodeMirror's own, so it runs
// after the deferral decision but before the deferred dispatch)
// lets the deferred Enter be re-presented with Shift+Enter intact
// instead of arriving as a plain Enter that "sends" where Enter
// sends. Without it, Shift+Enter on iOS/Android submits the
// message instead of inserting a newline. The listener lives on
// the kept-alive view's contentDOM, so it stays across mounts and
// keeps feeding the same ref the `interceptKeys` closure reads.
const trackRealEnterShift = (event: KeyboardEvent) => {
if (event.key !== 'Enter' || isDeferredSyntheticEvent(event)) return;
lastRealEnterShiftRef.current = event.shiftKey;
};
view.contentDOM.addEventListener('keydown', trackRealEnterShift);
return () => {
viewRef.current = null;
// A stored view is detached, not destroyed: the store owns its