Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fix-android-emote-backspace-keyboard.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
default: patch
---

Fix the Android keyboard flickering when backspacing a native emote.
5 changes: 5 additions & 0 deletions .changeset/fix-mobile-image-tap-keyboard.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
default: patch
---

Fix the mobile keyboard reopening when tapping a timeline image.
32 changes: 30 additions & 2 deletions src/app/components/editor/Editor.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -225,7 +225,18 @@ describe('CustomEditor paste', () => {
const focusableRival = () => document.body.appendChild(document.createElement('button'));

describe('CustomEditor mobile keyboard', () => {
it('refocuses the editor when focus moves to a non-editable element on mobile', () => {
it('refocuses the editor when focus moves within the composer on mobile', () => {
platformState.isMobile = true;
const { container, editor } = renderEditor({ after: <button type="button">Send</button> });
const focusSpy = vi.spyOn(editor, 'focus');
const editable = container.querySelector('.ProseMirror') as HTMLElement;
const composerButton = screen.getByRole('button', { name: 'Send' });
editable.focus();
composerButton.focus();
expect(focusSpy).toHaveBeenCalledOnce();
});

it('yields focus when it moves outside the composer on mobile', () => {
platformState.isMobile = true;
const { container, editor } = renderEditor();
const focusSpy = vi.spyOn(editor, 'focus');
Expand All @@ -234,7 +245,7 @@ describe('CustomEditor mobile keyboard', () => {
try {
editable.focus();
rival.focus();
expect(focusSpy).toHaveBeenCalledOnce();
expect(focusSpy).not.toHaveBeenCalled();
} finally {
rival.remove();
}
Expand Down Expand Up @@ -279,4 +290,21 @@ describe('CustomEditor mobile keyboard', () => {
rival.remove();
}
});

it('refocuses when an autocomplete menu holds focus so picking a suggestion keeps the keyboard', () => {
platformState.isMobile = true;
const { container, editor } = renderEditor();
const focusSpy = vi.spyOn(editor, 'focus');
const editable = container.querySelector('.ProseMirror') as HTMLElement;
const menuItem = document.createElement('button');
menuItem.dataset.autocompleteMenu = 'true';
document.body.appendChild(menuItem);
try {
editable.focus();
menuItem.focus();
expect(focusSpy).toHaveBeenCalledOnce();
} finally {
menuItem.remove();
}
});
});
3 changes: 3 additions & 0 deletions src/app/components/editor/Editor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,9 @@ export const CustomEditor = forwardRef<HTMLDivElement, CustomEditorProps>(
if (!isMobileOrTablet() || suppressBlurRefocusRef?.current) return;
const next = event.relatedTarget as HTMLElement | null;
if (!next || next.isContentEditable) return;
// Only reclaim focus when it moved within the composer, so taps on
// the timeline (e.g. images) dismiss the keyboard.
if (!rootRef.current?.contains(next) && !next.closest('[data-autocomplete-menu]')) return;
editor.focus();
},
focus: () => {
Expand Down
86 changes: 85 additions & 1 deletion src/app/components/editor/prosemirrorController.test.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { act, fireEvent, render } from '@testing-library/react';
import { beforeAll, describe, expect, it, vi } from 'vitest';
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
import { Selection } from 'prosemirror-state';
import type { EditorView } from 'prosemirror-view';
import type { EditorDocument } from './model';
Expand All @@ -10,14 +10,24 @@ import { BlockType } from './types';
// ProseMirror scrolls the selection into view after a transaction, which needs
// client rects jsdom does not implement.
const emptyClientRects = () => [] as unknown as DOMRectList;
const emptyClientRect = () =>
({ top: 0, left: 0, right: 0, bottom: 0, width: 0, height: 0 }) as DOMRect;
beforeAll(() => {
Element.prototype.getClientRects ??= emptyClientRects;
(Text.prototype as unknown as Element).getClientRects ??= emptyClientRects;
(Range.prototype as unknown as Element).getClientRects ??= emptyClientRects;
(Range.prototype as unknown as Element).getBoundingClientRect ??= emptyClientRect;
});

const doc = (...texts: string[]): EditorDocument =>
texts.map((text) => ({ type: BlockType.Paragraph, children: [{ text }] }));

const beforeinput = (el: HTMLElement, inputType: string) => {
const event = new Event('beforeinput', { bubbles: true, cancelable: true });
Object.assign(event, { inputType });
fireEvent(el, event);
};

const mount = (initial: EditorDocument = doc('')) => {
const controller = new ProseMirrorEditorController(initial);
const result = render(<ProseMirrorEditable controller={controller} />);
Expand Down Expand Up @@ -259,3 +269,77 @@ describe('ProseMirrorEditorController clear', () => {
expect(editable).toHaveAttribute('data-placeholder-visible', 'true');
});
});

describe('Android backspace fallback', () => {
const originalUserAgent = navigator.userAgent;

const setAndroid = (android: boolean) => {
Object.defineProperty(navigator, 'userAgent', {
configurable: true,
value: android
? 'Mozilla/5.0 (Linux; Android 14; Pixel 8) Mobile Safari/537.36'
: originalUserAgent,
});
};

afterEach(() => {
setAndroid(false);
vi.useRealTimers();
});

it('deletes backward via the state when the IME leaves the DOM untouched, without blurring', () => {
setAndroid(true);
vi.useFakeTimers();
const { caretToEnd, controller, editable } = mount(doc('hi'));
caretToEnd();
editable.focus();
const onBlur = vi.fn<() => void>();
editable.addEventListener('blur', onBlur);

beforeinput(editable, 'deleteContentBackward');
act(() => vi.advanceTimersByTime(50));

expect(controller.getDocument()).toEqual(doc('h'));
expect(onBlur).not.toHaveBeenCalled();
});

it('leaves the deletion to the observer once the state already changed', () => {
setAndroid(true);
vi.useFakeTimers();
const { caretToEnd, controller, editable, view } = mount(doc('hi'));
caretToEnd();

beforeinput(editable, 'deleteContentBackward');
// The IME deleted in the DOM and the observer applied it first.
act(() => {
const from = view.state.selection.from;
view.dispatch(view.state.tr.delete(from - 1, from));
vi.advanceTimersByTime(50);
});

expect(controller.getDocument()).toEqual(doc('h'));
});

it('does not intercept Android input other than backward deletes', () => {
setAndroid(true);
vi.useFakeTimers();
const { caretToEnd, controller, editable } = mount(doc('hi'));
caretToEnd();

beforeinput(editable, 'insertText');
act(() => vi.advanceTimersByTime(50));

expect(controller.getDocument()).toEqual(doc('hi'));
});

it('leaves non-Android devices to the built-in handler', () => {
vi.useFakeTimers();
const { caretToEnd, controller, editable } = mount(doc('hi'));
caretToEnd();

beforeinput(editable, 'deleteContentBackward');
act(() => vi.advanceTimersByTime(50));

expect(controller.getDocument()).toEqual(doc('hi'));
});
});
41 changes: 41 additions & 0 deletions src/app/components/editor/prosemirrorController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,41 @@ import {
const isProseMirrorDocumentEmpty = (doc: ProseMirrorNode): boolean =>
doc.childCount === 1 && doc.firstChild?.content.size === 0;

// Mirrors prosemirror-view's own Android gate for its backspace fallback.
const isAndroid = (): boolean => /Android \d/.test(navigator.userAgent);

const androidBackspaceKeyEvent = (): KeyboardEvent =>
new KeyboardEvent('keydown', {
key: 'Backspace',
code: 'Backspace',
bubbles: true,
cancelable: true,
});

/**
* prosemirror-view resyncs an Android backspace that left the DOM untouched by
* blurring and refocusing the editable, which flashes the soft keyboard shut.
* Delete through the state instead.
*/
const handleAndroidDeleteBackward = (view: EditorView): void => {
const cursor =
view.state.selection instanceof TextSelection ? view.state.selection.$cursor : null;
if (!cursor || cursor.pos <= 0) return;
const pos = cursor.pos;
const contentSize = view.state.doc.content.size;
window.setTimeout(() => {
// The IME deleted in the DOM; leave it to the DOM observer.
const cursorAfter =
view.state.selection instanceof TextSelection ? view.state.selection.$cursor : null;
if (!cursorAfter || cursorAfter.pos !== pos) return;
if (view.state.doc.content.size !== contentSize) return;
// Prefer the keymap so atom nodes follow desktop Backspace behavior.
if (view.someProp('handleKeyDown', (handler) => handler(view, androidBackspaceKeyEvent())))
return;
view.dispatch(view.state.tr.delete(pos - 1, pos));
}, 50);
};

export type EditorAutocompleteQuery<TPrefix extends string> = {
from: number;
prefix: TPrefix;
Expand Down Expand Up @@ -165,6 +200,12 @@ export class ProseMirrorEditorController {
this.domEventHandlers.blur?.(event as FocusEvent);
return false;
},
beforeinput: (view, event) => {
if (!isAndroid() || (event as InputEvent).inputType !== 'deleteContentBackward')
return false;
handleAndroidDeleteBackward(view);
return true;
},
},
handlePaste: (view, event) => {
const text = event.clipboardData?.getData('text/plain');
Expand Down
Loading