From 2b1e69d4b0bfbca3f6fd4ef6f3a7a7dc8ed814f5 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Tue, 11 Aug 2026 13:08:50 -0700 Subject: [PATCH] Add keyboard dismissal for mobile markdown editor (#13856) * fix(mobile-markdown): enable keyboard dismissal while editing Allow users to dismiss the soft keyboard while composing markdown content. Extract the MarkdownReader component into its own file and add WebView-based caret preservation to restore the cursor position after the keyboard closes. This prevents the editor from losing focus and erasing the user's selected caret location when the keyboard hides. * improve test --- .../app/h/[hostId]/session/[worktreeId].tsx | 133 +---------- .../MobileRichMarkdownEditor.test.tsx | 84 +++++++ .../components/MobileRichMarkdownEditor.tsx | 39 +++- .../mobile-rich-markdown-editor-html.test.ts | 206 +++++++++++++++++- .../mobile-rich-markdown-editor-html.ts | 57 ++--- ...e-rich-markdown-keyboard-dismiss-script.ts | 13 ++ .../mobile-rich-markdown-selection-script.ts | 71 ++++++ .../src/session/MobileMarkdownReader.test.tsx | 119 ++++++++++ mobile/src/session/MobileMarkdownReader.tsx | 172 +++++++++++++++ .../markdown-floating-actions-layout.test.ts | 30 ++- .../markdown-floating-actions-layout.ts | 16 ++ .../mobile-session-review-comment-styles.ts | 5 + 12 files changed, 766 insertions(+), 179 deletions(-) create mode 100644 mobile/src/components/MobileRichMarkdownEditor.test.tsx create mode 100644 mobile/src/components/mobile-rich-markdown-keyboard-dismiss-script.ts create mode 100644 mobile/src/components/mobile-rich-markdown-selection-script.ts create mode 100644 mobile/src/session/MobileMarkdownReader.test.tsx create mode 100644 mobile/src/session/MobileMarkdownReader.tsx diff --git a/mobile/app/h/[hostId]/session/[worktreeId].tsx b/mobile/app/h/[hostId]/session/[worktreeId].tsx index 105f14e8c70..cf0f1dd7edb 100644 --- a/mobile/app/h/[hostId]/session/[worktreeId].tsx +++ b/mobile/app/h/[hostId]/session/[worktreeId].tsx @@ -154,7 +154,7 @@ import { ActionSheetModal } from '../../../../src/components/ActionSheetModal' import { MobileAgentIcon } from '../../../../src/components/MobileAgentIcon' import { TextInputModal } from '../../../../src/components/TextInputModal' import { ConfirmModal } from '../../../../src/components/ConfirmModal' -import { MobileRichMarkdownEditor } from '../../../../src/components/MobileRichMarkdownEditor' +import { MobileMarkdownReader } from '../../../../src/session/MobileMarkdownReader' import { MobileSyntaxSegments } from '../../../../src/components/MobileSyntaxSegments' import { CustomKeyModal, @@ -267,7 +267,6 @@ import { TERMINAL_GESTURE_INPUT_REFILL_PER_SECOND, updateTerminalCwdFromStreamEvent } from '../../../../src/session/mobile-session-route-helpers' -import { resolveMarkdownFloatingActionsBottom } from '../../../../src/session/markdown-floating-actions-layout' import { resolveTabStripScrollOffset } from '../../../../src/session/tab-strip-scroll' import { activateOpenedSourceControlDiffTab } from '../../../../src/session/opened-mobile-session-tab' import { @@ -275,7 +274,7 @@ import { dismissMobileSessionCreateWarningState, reconcileMobileSessionCreateWarningState } from '../../../../src/session/mobile-session-create-warning-state' -import { colors, spacing } from '../../../../src/theme/mobile-theme' +import { colors } from '../../../../src/theme/mobile-theme' import { QuickCommandsTabButton } from '../../../../src/session/QuickCommandsTabButton' import { styles } from '../../../../src/session/mobile-session-styles' import type { DiffComment, TerminalQuickCommand } from '../../../../../src/shared/types' @@ -302,132 +301,6 @@ import type { const TERMINAL_KEYBOARD_DISMISS_ACTION_SHEET_FALLBACK_MS = 450 -function MarkdownReader({ - documentId, - doc, - onRefresh, - onChange, - onSave, - onCopy, - onDiscard, - keyboardLift -}: { - documentId: string - doc: MarkdownDocState | undefined - onRefresh: () => void - onChange: (content: string) => void - onSave: () => void - onCopy: () => void - onDiscard: () => void - keyboardLift: number -}) { - // Native Keyboard events under-report the WebView editor's covered area, so prefer the larger WebView-measured inset. - const [webviewKeyboardInset, setWebviewKeyboardInset] = useState(0) - const effectiveKeyboardLift = Math.max(keyboardLift, webviewKeyboardInset) - if (!doc || doc.status === 'loading') { - return ( - - - - ) - } - if (doc.status === 'error') { - return ( - - {doc.message} - - - Retry - - - ) - } - - const statusText = doc.saveError - ? doc.saveError - : doc.readOnlyReason - ? 'Read only' - : doc.stale - ? 'Changed on desktop' - : null - const showRefresh = (doc.stale && !doc.isDirty) || !doc.editable - const showCopy = doc.saveError || !doc.editable - const showSave = doc.isDirty || doc.saving - const showFloatingActions = statusText || showRefresh || showCopy || showSave - - return ( - - - {showFloatingActions ? ( - - {statusText ? ( - - {statusText} - - ) : null} - - {showCopy ? ( - - Copy - - ) : null} - {showRefresh ? ( - - - Refresh - - ) : null} - {doc.isDirty ? ( - - Discard - - ) : null} - {showSave ? ( - - {doc.saving ? ( - - ) : ( - Save - )} - - ) : null} - - - ) : null} - - ) -} - function DiffLineRow({ line, title, @@ -4636,7 +4509,7 @@ export default function SessionScreen() { ) : activeMarkdownTab ? ( - void readMarkdownTab(activeMarkdownTab)} diff --git a/mobile/src/components/MobileRichMarkdownEditor.test.tsx b/mobile/src/components/MobileRichMarkdownEditor.test.tsx new file mode 100644 index 00000000000..20b17098d14 --- /dev/null +++ b/mobile/src/components/MobileRichMarkdownEditor.test.tsx @@ -0,0 +1,84 @@ +import { createElement, createRef, forwardRef, useImperativeHandle } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + MobileRichMarkdownEditor, + type MobileRichMarkdownEditorHandle +} from './MobileRichMarkdownEditor' + +const mocks = vi.hoisted(() => ({ + dismissKeyboard: vi.fn(), + injectJavaScript: vi.fn() +})) + +vi.mock('react-native', async () => { + const React = await import('react') + return { + Keyboard: { dismiss: mocks.dismissKeyboard }, + Linking: { openURL: vi.fn() }, + Pressable: 'Pressable', + ScrollView: ({ children, ...props }: { children?: unknown }) => + React.createElement('ScrollView', props, children), + StyleSheet: { create: (styles: unknown) => styles, hairlineWidth: 1 }, + View: 'View' + } +}) + +vi.mock('react-native-webview', () => { + const WebView = forwardRef((props: Record, ref) => { + useImperativeHandle(ref, () => ({ injectJavaScript: mocks.injectJavaScript })) + return createElement('WebView', props) + }) + return { WebView, default: WebView } +}) + +vi.mock('lucide-react-native', () => ({ + Bold: 'Bold', + ChevronDown: 'ChevronDown', + Code2: 'Code2', + FileCode2: 'FileCode2', + Heading1: 'Heading1', + Heading2: 'Heading2', + Heading3: 'Heading3', + ImageIcon: 'ImageIcon', + Italic: 'Italic', + Keyboard: 'Keyboard', + Link: 'Link', + List: 'List', + ListOrdered: 'ListOrdered', + ListTodo: 'ListTodo', + Pilcrow: 'Pilcrow', + Quote: 'Quote', + Strikethrough: 'Strikethrough' +})) + +describe('MobileRichMarkdownEditor', () => { + let renderer: ReactTestRenderer | null = null + + afterEach(() => { + act(() => renderer?.unmount()) + renderer = null + vi.clearAllMocks() + }) + + it('exposes WebView keyboard dismissal to its native parent', () => { + const editorRef = createRef() + act(() => { + renderer = create( + createElement(MobileRichMarkdownEditor, { + ref: editorRef, + content: '', + editable: true, + onChange: vi.fn() + }) + ) + }) + + act(() => editorRef.current?.dismissKeyboard()) + + expect(mocks.injectJavaScript).toHaveBeenCalledWith( + expect.stringContaining('window.__orcaRichMarkdown.dismissKeyboard()') + ) + expect(mocks.dismissKeyboard).toHaveBeenCalledOnce() + }) +}) diff --git a/mobile/src/components/MobileRichMarkdownEditor.tsx b/mobile/src/components/MobileRichMarkdownEditor.tsx index c9e0b44e082..8fbcaa149e7 100644 --- a/mobile/src/components/MobileRichMarkdownEditor.tsx +++ b/mobile/src/components/MobileRichMarkdownEditor.tsx @@ -1,5 +1,15 @@ -import { memo, useCallback, useEffect, useMemo, useRef, type ComponentType } from 'react' -import { Linking, Pressable, ScrollView, StyleSheet, View } from 'react-native' +import { + forwardRef, + memo, + useCallback, + useEffect, + useImperativeHandle, + useMemo, + useRef, + type ComponentType, + type ForwardedRef +} from 'react' +import { Keyboard, Linking, Pressable, ScrollView, StyleSheet, View } from 'react-native' import { Bold, Code2, @@ -77,6 +87,10 @@ type Props = { onKeyboardInsetChange?: (bottom: number) => void } +export type MobileRichMarkdownEditorHandle = { + dismissKeyboard: () => void +} + type EditorWebViewMessage = | { type: 'ready' } | { type: 'change'; markdown: string; generation: number } @@ -107,12 +121,10 @@ const TOOLBAR_ITEMS: ToolbarItem[] = [ { command: 'codeBlock', label: 'Code block', icon: FileCode2 } ] -function MobileRichMarkdownEditorInner({ - content, - editable, - onChange, - onKeyboardInsetChange -}: Props) { +function MobileRichMarkdownEditorInner( + { content, editable, onChange, onKeyboardInsetChange }: Props, + ref: ForwardedRef +) { const webViewRef = useRef(null) const readyRef = useRef(false) const documentGenerationRef = useRef(0) @@ -227,6 +239,15 @@ function MobileRichMarkdownEditorInner({ [inject] ) + const dismissKeyboard = useCallback(() => { + // Why: the caret lives in the WebView, so the injected blur is what closes the keyboard; + // Keyboard.dismiss only clears a native TextInput that stole focus first. + inject('window.__orcaRichMarkdown && window.__orcaRichMarkdown.dismissKeyboard();') + Keyboard.dismiss() + }, [inject]) + + useImperativeHandle(ref, () => ({ dismissKeyboard }), [dismissKeyboard]) + return ( @@ -278,7 +299,7 @@ function MobileRichMarkdownEditorInner({ ) } -export const MobileRichMarkdownEditor = memo(MobileRichMarkdownEditorInner) +export const MobileRichMarkdownEditor = memo(forwardRef(MobileRichMarkdownEditorInner)) const styles = StyleSheet.create({ container: { diff --git a/mobile/src/components/mobile-rich-markdown-editor-html.test.ts b/mobile/src/components/mobile-rich-markdown-editor-html.test.ts index f21674478b1..b8321c7a2f2 100644 --- a/mobile/src/components/mobile-rich-markdown-editor-html.test.ts +++ b/mobile/src/components/mobile-rich-markdown-editor-html.test.ts @@ -11,9 +11,7 @@ function editorScript(): string { return script ?? '' } -function extractFunctionSource(script: string, name: string): string { - const start = script.indexOf(`function ${name}`) - expect(start).toBeGreaterThanOrEqual(0) +function extractBracedSource(script: string, start: number, label: string): string { const bodyStart = script.indexOf('{', start) let depth = 0 for (let index = bodyStart; index < script.length; index += 1) { @@ -28,7 +26,19 @@ function extractFunctionSource(script: string, name: string): string { } } } - throw new Error(`Could not extract ${name}`) + throw new Error(`Could not extract ${label}`) +} + +function extractFunctionSource(script: string, name: string): string { + const start = script.indexOf(`function ${name}`) + expect(start).toBeGreaterThanOrEqual(0) + return extractBracedSource(script, start, name) +} + +function extractEditorListenerSource(script: string, type: string): string { + const start = script.indexOf(`editor.addEventListener('${type}'`) + expect(start).toBeGreaterThanOrEqual(0) + return `${extractBracedSource(script, start, `${type} listener`)});` } function runtimeMarkdownToHtml(markdown: string, editable: boolean): string { @@ -65,6 +75,134 @@ function runtimeListMarkdown(): (list: unknown) => string { return new Function(sources)() as (list: unknown) => string } +type FakeRange = { + commonAncestorContainer: unknown + cloneRange: () => FakeRange + selectNodeContents: (node: unknown) => void + collapse: (toStart: boolean) => void +} + +function createFakeRange(container: unknown): FakeRange { + const range: FakeRange = { + commonAncestorContainer: container, + cloneRange: () => createFakeRange(range.commonAncestorContainer), + selectNodeContents: (node) => { + range.commonAncestorContainer = node + }, + collapse: () => {} + } + return range +} + +type FakeClickEvent = { + clientX: number + clientY: number + target: { closest: (selector: string) => unknown } + preventDefault: () => void +} + +// Drives the editor's real selection and click handling against a stub DOM whose blur +// drops the selection, the way WebKit does. +function createSelectionRuntime(caretContainer: string | null) { + const liveNodes = new Set(caretContainer ? [caretContainer] : []) + const listeners = new Map void>() + let focused = caretContainer != null + let ranges: FakeRange[] = caretContainer ? [createFakeRange(caretContainer)] : [] + let caretAtPoint: string | null = null + + const editor = { + contains: (node: unknown) => typeof node === 'string' && liveNodes.has(node), + focus: () => { + focused = true + fakeDocument.activeElement = editor + }, + blur: () => { + focused = false + fakeDocument.activeElement = null + ranges = [] + }, + addEventListener: (type: string, handler: (event: FakeClickEvent) => void) => { + listeners.set(type, handler) + } + } + const fakeDocument: { + activeElement: unknown + createRange: () => FakeRange + caretRangeFromPoint: () => FakeRange | null + } = { + activeElement: focused ? editor : null, + createRange: () => createFakeRange('detached'), + caretRangeFromPoint: () => (caretAtPoint ? createFakeRange(caretAtPoint) : null) + } + const fakeWindow = { + getSelection: () => ({ + get rangeCount() { + return ranges.length + }, + getRangeAt: (index: number) => ranges[index], + removeAllRanges: () => { + ranges = [] + }, + addRange: (range: FakeRange) => { + ranges = [range] + } + }) + } + + const script = editorScript() + const sources = [ + 'var editor = arguments[0];', + 'var document = arguments[1];', + 'var window = arguments[2];', + 'var editable = true;', + 'var savedSelectionRange = null;', + 'var selectionDroppedOnBlur = false;', + 'function post() {}', + 'function emitChange() {}', + extractFunctionSource(script, 'focusEditor'), + extractFunctionSource(script, 'rememberSelection'), + extractFunctionSource(script, 'applySelectionRange'), + extractFunctionSource(script, 'caretRangeAtPoint'), + extractFunctionSource(script, 'dismissKeyboard'), + extractFunctionSource(script, 'restoreSelectionOrEnd'), + extractEditorListenerSource(script, 'click'), + 'return { dismissKeyboard: dismissKeyboard, restoreSelectionOrEnd: restoreSelectionOrEnd };' + ].join('\n') + const api = new Function(sources)(editor, fakeDocument, fakeWindow) as { + dismissKeyboard: () => void + restoreSelectionOrEnd: () => void + } + + return { + dismissKeyboard: api.dismissKeyboard, + restoreSelectionOrEnd: api.restoreSelectionOrEnd, + tapAt: (container: string, options?: { uneditableAncestor?: string }) => { + liveNodes.add(container) + caretAtPoint = container + listeners.get('click')?.({ + clientX: 12, + clientY: 34, + target: { + closest: (selector: string) => + selector === '[contenteditable="false"]' ? (options?.uneditableAncestor ?? null) : null + }, + preventDefault: () => {} + }) + }, + detachEditorContent: () => liveNodes.clear(), + selectedContainer: () => { + if (ranges.length === 0) { + return null + } + const container = ranges[0].commonAncestorContainer + return container === editor ? 'editor-end' : (container as string) + }, + get focused() { + return focused + } + } +} + describe('mobile rich markdown editor HTML', () => { it('builds parseable WebView JavaScript', () => { const script = editorScript() @@ -233,4 +371,64 @@ describe('mobile rich markdown editor HTML', () => { expect(emitChange).not.toContain('window.setTimeout') expect(emitChange).toContain('generation: pendingGeneration') }) + + it('exposes a keyboard dismissal command that blurs WebView focus', () => { + const script = editorScript() + const dismissKeyboard = extractFunctionSource(script, 'dismissKeyboard') + + expect(dismissKeyboard).toContain('rememberSelection()') + expect(dismissKeyboard).toContain('document.activeElement.blur()') + expect(dismissKeyboard).toContain('editor.blur()') + expect(script).toContain('dismissKeyboard: dismissKeyboard') + }) + + it('reclaims editor focus at the tapped caret after keyboard dismissal', () => { + const runtime = createSelectionRuntime('paragraph-3') + + runtime.dismissKeyboard() + runtime.tapAt('paragraph-7') + + expect(runtime.focused).toBe(true) + expect(runtime.selectedContainer()).toBe('paragraph-7') + }) + + it('leaves task-list label taps to the checkbox instead of refocusing the editor', () => { + const runtime = createSelectionRuntime('paragraph-3') + + runtime.dismissKeyboard() + runtime.tapAt('paragraph-7', { uneditableAncestor: 'task-label' }) + + expect(runtime.focused).toBe(false) + expect(runtime.selectedContainer()).toBe(null) + }) + + it('restores the pre-dismissal caret so commands do not insert at the document end', () => { + const runtime = createSelectionRuntime('paragraph-3') + + runtime.dismissKeyboard() + expect(runtime.selectedContainer()).toBe(null) + + runtime.restoreSelectionOrEnd() + + expect(runtime.selectedContainer()).toBe('paragraph-3') + expect(runtime.focused).toBe(true) + }) + + it('falls back to the document end when no caret was ever placed', () => { + const runtime = createSelectionRuntime(null) + + runtime.restoreSelectionOrEnd() + + expect(runtime.selectedContainer()).toBe('editor-end') + }) + + it('drops a remembered caret whose nodes left the document', () => { + const runtime = createSelectionRuntime('paragraph-3') + + runtime.dismissKeyboard() + runtime.detachEditorContent() + runtime.restoreSelectionOrEnd() + + expect(runtime.selectedContainer()).toBe('editor-end') + }) }) diff --git a/mobile/src/components/mobile-rich-markdown-editor-html.ts b/mobile/src/components/mobile-rich-markdown-editor-html.ts index 32fc12b59b2..b7ff1649075 100644 --- a/mobile/src/components/mobile-rich-markdown-editor-html.ts +++ b/mobile/src/components/mobile-rich-markdown-editor-html.ts @@ -1,5 +1,7 @@ import { colors } from '../theme/mobile-theme' +import { MOBILE_RICH_MARKDOWN_KEYBOARD_DISMISS_SCRIPT } from './mobile-rich-markdown-keyboard-dismiss-script' import { MOBILE_RICH_MARKDOWN_KEYBOARD_INSET_SCRIPT } from './mobile-rich-markdown-editor-keyboard-inset-script' +import { MOBILE_RICH_MARKDOWN_SELECTION_SCRIPT } from './mobile-rich-markdown-selection-script' export function escapeInjectedJavaScriptString(value: string): string { return JSON.stringify(value).replace(/<\/script/gi, '<\\/script') @@ -559,6 +561,9 @@ export function buildMobileRichMarkdownEditorHtml(): string { window.clearTimeout(inputTimer); documentGeneration = Number(generation) || 0; suppressInput = true; + // Why: replacing innerHTML detaches the remembered caret's nodes. + savedSelectionRange = null; + selectionDroppedOnBlur = false; lastMarkdown = String(markdown || ''); editor.innerHTML = markdownToHtml(lastMarkdown); syncTaskCheckboxesDisabled(); @@ -571,39 +576,8 @@ export function buildMobileRichMarkdownEditorHtml(): string { syncTaskCheckboxesDisabled(); } - function focusEditor() { - editor.focus(); - } - - function restoreSelectionOrEnd() { - focusEditor(); - var selection = window.getSelection(); - if (!selection || selection.rangeCount > 0) return; - var range = document.createRange(); - range.selectNodeContents(editor); - range.collapse(false); - selection.removeAllRanges(); - selection.addRange(range); - } - - function wrapSelection(tagName) { - restoreSelectionOrEnd(); - var selection = window.getSelection(); - if (!selection || selection.rangeCount === 0) return; - var range = selection.getRangeAt(0); - if (range.collapsed) return; - var wrapper = document.createElement(tagName); - try { - range.surroundContents(wrapper); - } catch (_error) { - wrapper.appendChild(range.extractContents()); - range.insertNode(wrapper); - } - selection.removeAllRanges(); - selection.selectAllChildren(wrapper); - emitChange(); - } - +${MOBILE_RICH_MARKDOWN_SELECTION_SCRIPT} +${MOBILE_RICH_MARKDOWN_KEYBOARD_DISMISS_SCRIPT} function runCommand(command) { if (!editable || editor.getAttribute('contenteditable') !== 'true') return; restoreSelectionOrEnd(); @@ -632,6 +606,7 @@ export function buildMobileRichMarkdownEditorHtml(): string { } editor.addEventListener('input', function () { + selectionDroppedOnBlur = false; if (editable) emitChange(); }); editor.addEventListener('change', function (event) { @@ -654,7 +629,19 @@ export function buildMobileRichMarkdownEditorHtml(): string { return; } var input = event.target && event.target.closest && event.target.closest('input[type="checkbox"]'); - if (!input) return; + if (!input) { + if (!editable) return; + // Why: a task-list label forwards its click to the checkbox, so refocusing here would steal it and re-open the keyboard. + var uneditable = event.target && event.target.closest && event.target.closest('[contenteditable="false"]'); + if (uneditable && uneditable !== editor) return; + selectionDroppedOnBlur = false; + if (document.activeElement === editor) return; + // Why: refocusing after a dismissal otherwise types at the stale caret, not where the user tapped. + var caret = caretRangeAtPoint(event.clientX, event.clientY); + focusEditor(); + if (caret && editor.contains(caret.commonAncestorContainer)) applySelectionRange(caret); + return; + } if (!editable) { event.preventDefault(); return; @@ -670,7 +657,7 @@ export function buildMobileRichMarkdownEditorHtml(): string { } }); - window.__orcaRichMarkdown = { setMarkdown: setMarkdown, setEditable: setEditable, runCommand: runCommand, currentMarkdown: currentMarkdown }; + window.__orcaRichMarkdown = { setMarkdown: setMarkdown, setEditable: setEditable, runCommand: runCommand, currentMarkdown: currentMarkdown, dismissKeyboard: dismissKeyboard }; ${MOBILE_RICH_MARKDOWN_KEYBOARD_INSET_SCRIPT} post({ type: 'ready' }); })(); diff --git a/mobile/src/components/mobile-rich-markdown-keyboard-dismiss-script.ts b/mobile/src/components/mobile-rich-markdown-keyboard-dismiss-script.ts new file mode 100644 index 00000000000..a4520ce4261 --- /dev/null +++ b/mobile/src/components/mobile-rich-markdown-keyboard-dismiss-script.ts @@ -0,0 +1,13 @@ +// Composed into the editor document after MOBILE_RICH_MARKDOWN_SELECTION_SCRIPT, whose +// rememberSelection/selectionDroppedOnBlur this depends on. +export const MOBILE_RICH_MARKDOWN_KEYBOARD_DISMISS_SCRIPT = ` + function dismissKeyboard() { + // Why: WebKit discards the DOM selection on blur, so capture the caret before it goes. + rememberSelection(); + selectionDroppedOnBlur = true; + if (document.activeElement && document.activeElement.blur) { + document.activeElement.blur(); + } + editor.blur(); + } +` diff --git a/mobile/src/components/mobile-rich-markdown-selection-script.ts b/mobile/src/components/mobile-rich-markdown-selection-script.ts new file mode 100644 index 00000000000..c649bd09ed8 --- /dev/null +++ b/mobile/src/components/mobile-rich-markdown-selection-script.ts @@ -0,0 +1,71 @@ +// Caret and selection management for the WebView editor. Split out so the editor +// document script stays inside its line budget. +export const MOBILE_RICH_MARKDOWN_SELECTION_SCRIPT = ` + var savedSelectionRange = null; + var selectionDroppedOnBlur = false; + + function focusEditor() { + editor.focus(); + } + + function rememberSelection() { + var selection = window.getSelection(); + if (!selection || selection.rangeCount === 0) return; + var range = selection.getRangeAt(0); + if (editor.contains(range.commonAncestorContainer)) savedSelectionRange = range.cloneRange(); + } + + function applySelectionRange(range) { + var selection = window.getSelection(); + if (!selection) return; + selection.removeAllRanges(); + selection.addRange(range); + savedSelectionRange = range.cloneRange(); + } + + function caretRangeAtPoint(x, y) { + if (document.caretRangeFromPoint) return document.caretRangeFromPoint(x, y); + if (!document.caretPositionFromPoint) return null; + var position = document.caretPositionFromPoint(x, y); + if (!position) return null; + var range = document.createRange(); + range.setStart(position.offsetNode, position.offset); + range.collapse(true); + return range; + } + + function restoreSelectionOrEnd() { + focusEditor(); + var selection = window.getSelection(); + if (!selection) return; + // Why: the blur dropped the live selection, so commands would otherwise insert at the document end. + if (selectionDroppedOnBlur && savedSelectionRange && editor.contains(savedSelectionRange.commonAncestorContainer)) { + selectionDroppedOnBlur = false; + applySelectionRange(savedSelectionRange); + return; + } + if (selection.rangeCount > 0) return; + var range = document.createRange(); + range.selectNodeContents(editor); + range.collapse(false); + applySelectionRange(range); + } + + function wrapSelection(tagName) { + restoreSelectionOrEnd(); + var selection = window.getSelection(); + if (!selection || selection.rangeCount === 0) return; + var range = selection.getRangeAt(0); + if (range.collapsed) return; + var wrapper = document.createElement(tagName); + try { + range.surroundContents(wrapper); + } catch (_error) { + wrapper.appendChild(range.extractContents()); + range.insertNode(wrapper); + } + selection.removeAllRanges(); + selection.selectAllChildren(wrapper); + emitChange(); + } +` diff --git a/mobile/src/session/MobileMarkdownReader.test.tsx b/mobile/src/session/MobileMarkdownReader.test.tsx new file mode 100644 index 00000000000..c6ff322acba --- /dev/null +++ b/mobile/src/session/MobileMarkdownReader.test.tsx @@ -0,0 +1,119 @@ +import { createElement, forwardRef, useImperativeHandle } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { MobileMarkdownReader } from './MobileMarkdownReader' +import type { MarkdownDocState } from './mobile-session-route-types' + +const mocks = vi.hoisted(() => ({ + dismissKeyboard: vi.fn(), + reportKeyboardInset: null as ((bottom: number) => void) | null +})) + +vi.mock('react-native', async () => { + const React = await import('react') + return { + ActivityIndicator: 'ActivityIndicator', + Platform: { OS: 'ios', select: (choices: Record) => choices.ios }, + Pressable: ({ children, ...props }: { children?: unknown }) => + React.createElement('Pressable', props, children), + StyleSheet: { create: (styles: unknown) => styles, hairlineWidth: 1 }, + Text: 'Text', + View: 'View' + } +}) + +vi.mock('lucide-react-native', () => ({ + ChevronDown: 'ChevronDown', + Keyboard: 'KeyboardIcon', + RefreshCw: 'RefreshCw' +})) + +vi.mock('../components/MobileRichMarkdownEditor', () => { + const Editor = forwardRef( + (props: { onKeyboardInsetChange?: (bottom: number) => void }, ref: unknown) => { + mocks.reportKeyboardInset = props.onKeyboardInsetChange ?? null + useImperativeHandle(ref as never, () => ({ dismissKeyboard: mocks.dismissKeyboard })) + return createElement('MobileRichMarkdownEditor') + } + ) + return { MobileRichMarkdownEditor: Editor } +}) + +const readyDoc: MarkdownDocState = { + status: 'ready', + content: '# Notes', + localContent: '# Notes', + baseVersion: '1', + isDirty: false, + editable: true +} + +describe('MobileMarkdownReader', () => { + let renderer: ReactTestRenderer | null = null + + function render(keyboardLift: number, doc: MarkdownDocState = readyDoc): ReactTestRenderer { + act(() => { + renderer = create( + createElement(MobileMarkdownReader, { + documentId: 'doc-1', + doc, + keyboardLift, + onRefresh: vi.fn(), + onChange: vi.fn(), + onSave: vi.fn(), + onCopy: vi.fn(), + onDiscard: vi.fn() + }) + ) + }) + return renderer as unknown as ReactTestRenderer + } + + function dismissButtons(instance: ReactTestRenderer) { + return instance.root.findAll( + (node) => + typeof node.type === 'string' && node.props?.accessibilityLabel === 'Dismiss keyboard' + ) + } + + afterEach(() => { + act(() => renderer?.unmount()) + renderer = null + mocks.reportKeyboardInset = null + vi.clearAllMocks() + }) + + it('offers keyboard dismissal while the keyboard covers the editor', () => { + const instance = render(291) + + const [button] = dismissButtons(instance) + expect(button).toBeDefined() + + act(() => button.props.onPress()) + + expect(mocks.dismissKeyboard).toHaveBeenCalledOnce() + }) + + it('hides the floating row entirely on a clean document with the keyboard closed', () => { + const instance = render(0) + + expect(dismissButtons(instance)).toHaveLength(0) + expect(instance.root.findAllByType('Text')).toHaveLength(0) + }) + + it('keeps document actions available without offering dismissal when the keyboard is closed', () => { + const instance = render(0, { ...readyDoc, isDirty: true }) + + expect(dismissButtons(instance)).toHaveLength(0) + expect(instance.root.findAllByType('Text').length).toBeGreaterThan(0) + }) + + it('treats a WebView-reported inset as an open keyboard when native lift reports none', () => { + const instance = render(0) + expect(dismissButtons(instance)).toHaveLength(0) + + act(() => mocks.reportKeyboardInset?.(291)) + + expect(dismissButtons(instance)).toHaveLength(1) + }) +}) diff --git a/mobile/src/session/MobileMarkdownReader.tsx b/mobile/src/session/MobileMarkdownReader.tsx new file mode 100644 index 00000000000..a84599ded3a --- /dev/null +++ b/mobile/src/session/MobileMarkdownReader.tsx @@ -0,0 +1,172 @@ +import { useRef, useState } from 'react' +import { ActivityIndicator, Pressable, Text, View } from 'react-native' +import { ChevronDown, Keyboard as KeyboardIcon, RefreshCw } from 'lucide-react-native' +import { + MobileRichMarkdownEditor, + type MobileRichMarkdownEditorHandle +} from '../components/MobileRichMarkdownEditor' +import { colors, spacing } from '../theme/mobile-theme' +import { + resolveMarkdownFloatingActionsBottom, + shouldShowMarkdownFloatingActions +} from './markdown-floating-actions-layout' +import type { MarkdownDocState } from './mobile-session-route-types' +import { styles } from './mobile-session-styles' + +type Props = { + documentId: string + doc: MarkdownDocState | undefined + onRefresh: () => void + onChange: (content: string) => void + onSave: () => void + onCopy: () => void + onDiscard: () => void + keyboardLift: number +} + +export function MobileMarkdownReader({ + documentId, + doc, + onRefresh, + onChange, + onSave, + onCopy, + onDiscard, + keyboardLift +}: Props) { + const editorRef = useRef(null) + // Native Keyboard events under-report the WebView editor's covered area, so prefer the larger WebView-measured inset. + const [webviewKeyboardInset, setWebviewKeyboardInset] = useState(0) + const effectiveKeyboardLift = Math.max(keyboardLift, webviewKeyboardInset) + const keyboardOpen = effectiveKeyboardLift > 0 + + if (!doc || doc.status === 'loading') { + return ( + + + + ) + } + if (doc.status === 'error') { + return ( + + {doc.message} + + + Retry + + + ) + } + + const statusText = doc.saveError + ? doc.saveError + : doc.readOnlyReason + ? 'Read only' + : doc.stale + ? 'Changed on desktop' + : null + const showRefresh = Boolean((doc.stale && !doc.isDirty) || !doc.editable) + const showCopy = Boolean(doc.saveError || !doc.editable) + const showSave = Boolean(doc.isDirty || doc.saving) + const showFloatingActions = shouldShowMarkdownFloatingActions({ + keyboardLift: effectiveKeyboardLift, + hasStatus: statusText != null, + showRefresh, + showCopy, + showSave + }) + + return ( + + + {showFloatingActions ? ( + + {statusText ? ( + + {statusText} + + ) : null} + + {keyboardOpen ? ( + editorRef.current?.dismissKeyboard()} + hitSlop={8} + accessibilityRole="button" + accessibilityLabel="Dismiss keyboard" + accessibilityHint="Hides the software keyboard and keeps the markdown editor open." + > + + + + + + ) : null} + {showCopy ? ( + + Copy + + ) : null} + {showRefresh ? ( + + + Refresh + + ) : null} + {doc.isDirty ? ( + + Discard + + ) : null} + {showSave ? ( + + {doc.saving ? ( + + ) : ( + Save + )} + + ) : null} + + + ) : null} + + ) +} diff --git a/mobile/src/session/markdown-floating-actions-layout.test.ts b/mobile/src/session/markdown-floating-actions-layout.test.ts index 021b8de958c..a4e139acc2f 100644 --- a/mobile/src/session/markdown-floating-actions-layout.test.ts +++ b/mobile/src/session/markdown-floating-actions-layout.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it } from 'vitest' -import { resolveMarkdownFloatingActionsBottom } from './markdown-floating-actions-layout' +import { + resolveMarkdownFloatingActionsBottom, + shouldShowMarkdownFloatingActions +} from './markdown-floating-actions-layout' describe('resolveMarkdownFloatingActionsBottom', () => { it('keeps markdown actions at their resting bottom when the keyboard is closed', () => { @@ -22,3 +25,28 @@ describe('resolveMarkdownFloatingActionsBottom', () => { ).toBe(303) }) }) + +describe('shouldShowMarkdownFloatingActions', () => { + const idle = { + keyboardLift: 0, + hasStatus: false, + showRefresh: false, + showCopy: false, + showSave: false + } + + it('shows the floating row for keyboard dismissal without document actions', () => { + expect(shouldShowMarkdownFloatingActions({ ...idle, keyboardLift: 291 })).toBe(true) + }) + + it('hides the floating row on a clean document with the keyboard closed', () => { + expect(shouldShowMarkdownFloatingActions(idle)).toBe(false) + }) + + it.each(['hasStatus', 'showRefresh', 'showCopy', 'showSave'] as const)( + 'shows the floating row for %s alone', + (field) => { + expect(shouldShowMarkdownFloatingActions({ ...idle, [field]: true })).toBe(true) + } + ) +}) diff --git a/mobile/src/session/markdown-floating-actions-layout.ts b/mobile/src/session/markdown-floating-actions-layout.ts index 06aa5b9223f..bc506a88a9d 100644 --- a/mobile/src/session/markdown-floating-actions-layout.ts +++ b/mobile/src/session/markdown-floating-actions-layout.ts @@ -9,3 +9,19 @@ export function resolveMarkdownFloatingActionsBottom({ }): number { return keyboardLift > 0 ? keyboardLift + liftedClearance : restingBottom } + +export function shouldShowMarkdownFloatingActions({ + keyboardLift, + hasStatus, + showRefresh, + showCopy, + showSave +}: { + keyboardLift: number + hasStatus: boolean + showRefresh: boolean + showCopy: boolean + showSave: boolean +}): boolean { + return keyboardLift > 0 || hasStatus || showRefresh || showCopy || showSave +} diff --git a/mobile/src/session/mobile-session-review-comment-styles.ts b/mobile/src/session/mobile-session-review-comment-styles.ts index 8d8b2d11ccd..a3b80615446 100644 --- a/mobile/src/session/mobile-session-review-comment-styles.ts +++ b/mobile/src/session/mobile-session-review-comment-styles.ts @@ -159,6 +159,11 @@ export const mobileSessionReviewCommentStyles = StyleSheet.create({ paddingHorizontal: spacing.md, paddingVertical: spacing.xs }, + markdownKeyboardDismissButton: { + width: 34, + justifyContent: 'center', + paddingHorizontal: 0 + }, markdownSaveButton: { backgroundColor: colors.bgRaised },