mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 08:02:28 +00:00
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:
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user