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
},