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
This commit is contained in:
Jinjing
2026-08-11 13:08:50 -07:00
committed by GitHub
parent 92f928cf89
commit 2b1e69d4b0
12 changed files with 766 additions and 179 deletions
+3 -130
View File
@@ -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 (
<View style={styles.markdownState}>
<ActivityIndicator size="small" color={colors.textSecondary} />
</View>
)
}
if (doc.status === 'error') {
return (
<View style={styles.markdownState}>
<Text style={styles.markdownError}>{doc.message}</Text>
<Pressable style={styles.markdownRefreshButton} onPress={onRefresh}>
<RefreshCw size={14} color={colors.textPrimary} />
<Text style={styles.markdownRefreshText}>Retry</Text>
</Pressable>
</View>
)
}
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 (
<View style={styles.markdownEditor}>
<MobileRichMarkdownEditor
key={documentId}
content={doc.localContent}
editable={doc.editable && !doc.saving}
onChange={onChange}
onKeyboardInsetChange={setWebviewKeyboardInset}
/>
{showFloatingActions ? (
<View
pointerEvents="box-none"
style={[
styles.markdownFloatingBar,
// Why: editor focus lives in a WebView, so lift native Save/Discard controls instead of resizing it.
{
bottom: resolveMarkdownFloatingActionsBottom({
keyboardLift: effectiveKeyboardLift,
restingBottom: spacing.lg,
liftedClearance: spacing.md
})
}
]}
>
{statusText ? (
<Text
style={[styles.markdownFloatingStatus, doc.saveError ? styles.markdownError : null]}
numberOfLines={2}
>
{statusText}
</Text>
) : null}
<View style={styles.markdownFloatingActions}>
{showCopy ? (
<Pressable style={styles.markdownFloatingButton} onPress={onCopy}>
<Text style={styles.markdownFloatingButtonText}>Copy</Text>
</Pressable>
) : null}
{showRefresh ? (
<Pressable style={styles.markdownFloatingButton} onPress={onRefresh}>
<RefreshCw size={13} color={colors.textPrimary} />
<Text style={styles.markdownFloatingButtonText}>Refresh</Text>
</Pressable>
) : null}
{doc.isDirty ? (
<Pressable style={styles.markdownFloatingButton} onPress={onDiscard}>
<Text style={styles.markdownFloatingButtonText}>Discard</Text>
</Pressable>
) : null}
{showSave ? (
<Pressable
style={[
styles.markdownFloatingButton,
styles.markdownSaveButton,
(!doc.editable || !doc.isDirty || doc.saving) && styles.markdownButtonDisabled
]}
disabled={!doc.editable || !doc.isDirty || doc.saving}
onPress={onSave}
>
{doc.saving ? (
<ActivityIndicator size="small" color={colors.textPrimary} />
) : (
<Text style={styles.markdownFloatingButtonText}>Save</Text>
)}
</Pressable>
) : null}
</View>
</View>
) : null}
</View>
)
}
function DiffLineRow({
line,
title,
@@ -4636,7 +4509,7 @@ export default function SessionScreen() {
</View>
) : activeMarkdownTab ? (
<View style={styles.markdownFrame}>
<MarkdownReader
<MobileMarkdownReader
documentId={activeMarkdownTab.id}
doc={markdownDocs.get(activeMarkdownTab.id)}
onRefresh={() => void readMarkdownTab(activeMarkdownTab)}
@@ -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<string, unknown>, 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<MobileRichMarkdownEditorHandle>()
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()
})
})
@@ -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<MobileRichMarkdownEditorHandle>
) {
const webViewRef = useRef<WebView>(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 (
<View style={styles.container}>
<View style={styles.toolbar}>
@@ -278,7 +299,7 @@ function MobileRichMarkdownEditorInner({
)
}
export const MobileRichMarkdownEditor = memo(MobileRichMarkdownEditorInner)
export const MobileRichMarkdownEditor = memo(forwardRef(MobileRichMarkdownEditorInner))
const styles = StyleSheet.create({
container: {
@@ -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<string, (event: FakeClickEvent) => 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')
})
})
@@ -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' });
})();
@@ -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();
}
`
@@ -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();
}
`
@@ -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<string, unknown>) => 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)
})
})
+172
View File
@@ -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<MobileRichMarkdownEditorHandle>(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 (
<View style={styles.markdownState}>
<ActivityIndicator size="small" color={colors.textSecondary} />
</View>
)
}
if (doc.status === 'error') {
return (
<View style={styles.markdownState}>
<Text style={styles.markdownError}>{doc.message}</Text>
<Pressable style={styles.markdownRefreshButton} onPress={onRefresh}>
<RefreshCw size={14} color={colors.textPrimary} />
<Text style={styles.markdownRefreshText}>Retry</Text>
</Pressable>
</View>
)
}
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 (
<View style={styles.markdownEditor}>
<MobileRichMarkdownEditor
ref={editorRef}
key={documentId}
content={doc.localContent}
editable={doc.editable && !doc.saving}
onChange={onChange}
onKeyboardInsetChange={setWebviewKeyboardInset}
/>
{showFloatingActions ? (
<View
pointerEvents="box-none"
style={[
styles.markdownFloatingBar,
// Why: editor focus lives in a WebView, so lift native Save/Discard controls instead of resizing it.
{
bottom: resolveMarkdownFloatingActionsBottom({
keyboardLift: effectiveKeyboardLift,
restingBottom: spacing.lg,
liftedClearance: spacing.md
})
}
]}
>
{statusText ? (
<Text
style={[styles.markdownFloatingStatus, doc.saveError ? styles.markdownError : null]}
numberOfLines={2}
>
{statusText}
</Text>
) : null}
<View style={styles.markdownFloatingActions}>
{keyboardOpen ? (
<Pressable
style={[styles.markdownFloatingButton, styles.markdownKeyboardDismissButton]}
onPress={() => editorRef.current?.dismissKeyboard()}
hitSlop={8}
accessibilityRole="button"
accessibilityLabel="Dismiss keyboard"
accessibilityHint="Hides the software keyboard and keeps the markdown editor open."
>
<View style={styles.keyboardDismissGlyph}>
<KeyboardIcon size={15} color={colors.textSecondary} strokeWidth={2} />
<ChevronDown
size={10}
color={colors.textSecondary}
strokeWidth={2.5}
style={styles.keyboardDismissChevron}
/>
</View>
</Pressable>
) : null}
{showCopy ? (
<Pressable style={styles.markdownFloatingButton} onPress={onCopy}>
<Text style={styles.markdownFloatingButtonText}>Copy</Text>
</Pressable>
) : null}
{showRefresh ? (
<Pressable style={styles.markdownFloatingButton} onPress={onRefresh}>
<RefreshCw size={13} color={colors.textPrimary} />
<Text style={styles.markdownFloatingButtonText}>Refresh</Text>
</Pressable>
) : null}
{doc.isDirty ? (
<Pressable style={styles.markdownFloatingButton} onPress={onDiscard}>
<Text style={styles.markdownFloatingButtonText}>Discard</Text>
</Pressable>
) : null}
{showSave ? (
<Pressable
style={[
styles.markdownFloatingButton,
styles.markdownSaveButton,
(!doc.editable || !doc.isDirty || doc.saving) && styles.markdownButtonDisabled
]}
disabled={!doc.editable || !doc.isDirty || doc.saving}
onPress={onSave}
>
{doc.saving ? (
<ActivityIndicator size="small" color={colors.textPrimary} />
) : (
<Text style={styles.markdownFloatingButtonText}>Save</Text>
)}
</Pressable>
) : null}
</View>
</View>
) : null}
</View>
)
}
@@ -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)
}
)
})
@@ -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
}
@@ -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
},