refactor(mobile): the fifteen toolbar commands are one row both surfaces render

The row of controls is not the WebView's: a press becomes an injected
`runCommand` there and a call on the page, and neither difference belongs in the
toolbar. Extracted so the page's editor does not declare fifteen rows of its own
that would drift from the phone's.

`MobileRichMarkdownToolbar.test.tsx` adds the fence a second copy would have
needed: the row names every command in the contract, exactly once. Verified red
by dropping `codeBlock` from the row — "names every command in the contract,
once" failed on the 14-member list before the case went back. The native
component's own test and the web fallbacks file stay green unchanged, which is
what says the extraction moved nothing.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
This commit is contained in:
Jinwoo-H
2026-09-21 17:56:08 -04:00
parent 660f9704a3
commit 7751b83a9f
3 changed files with 243 additions and 98 deletions
@@ -5,30 +5,13 @@ import {
useImperativeHandle,
useMemo,
useRef,
type ComponentType,
type ForwardedRef
} from 'react'
import { Keyboard, Pressable, ScrollView, StyleSheet, View } from 'react-native'
import { Keyboard, StyleSheet, View } from 'react-native'
import { openExternalLink } from '../platform/external-link'
import {
Bold,
Code2,
FileCode2,
Heading1,
Heading2,
Heading3,
ImageIcon,
Italic,
Link,
List,
ListOrdered,
ListTodo,
Pilcrow,
Quote,
Strikethrough
} from 'lucide-react-native'
import WebView, { type WebViewMessageEvent } from 'react-native-webview'
import { colors, radii, spacing } from '../theme/mobile-theme'
import { colors } from '../theme/mobile-theme'
import { MobileRichMarkdownToolbar } from './MobileRichMarkdownToolbar'
import type {
MobileRichMarkdownCommand,
MobileRichMarkdownEditorMessage,
@@ -55,30 +38,6 @@ export type MobileRichMarkdownEditorHandle = {
dismissKeyboard: () => void
}
type ToolbarItem = {
command: MobileRichMarkdownCommand
label: string
icon: ComponentType<{ size?: number; color?: string }>
}
const TOOLBAR_ITEMS: ToolbarItem[] = [
{ command: 'paragraph', label: 'Body', icon: Pilcrow },
{ command: 'heading1', label: 'H1', icon: Heading1 },
{ command: 'heading2', label: 'H2', icon: Heading2 },
{ command: 'heading3', label: 'H3', icon: Heading3 },
{ command: 'bold', label: 'Bold', icon: Bold },
{ command: 'italic', label: 'Italic', icon: Italic },
{ command: 'strike', label: 'Strike', icon: Strikethrough },
{ command: 'bulletList', label: 'Bullet list', icon: List },
{ command: 'orderedList', label: 'Numbered list', icon: ListOrdered },
{ command: 'taskList', label: 'Checklist', icon: ListTodo },
{ command: 'quote', label: 'Quote', icon: Quote },
{ command: 'link', label: 'Link', icon: Link },
{ command: 'image', label: 'Image', icon: ImageIcon },
{ command: 'inlineCode', label: 'Inline code', icon: Code2 },
{ command: 'codeBlock', label: 'Code block', icon: FileCode2 }
]
function MobileRichMarkdownEditorInner(
{
content,
@@ -171,34 +130,7 @@ function MobileRichMarkdownEditorInner(
return (
<View style={styles.container}>
<View style={styles.toolbar}>
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={styles.toolbarContent}
keyboardShouldPersistTaps="handled"
>
{TOOLBAR_ITEMS.map((item) => {
const Icon = item.icon
return (
<Pressable
key={item.command}
disabled={!editable}
accessibilityRole="button"
accessibilityLabel={item.label}
onPress={() => runCommand(item.command)}
style={({ pressed }) => [
styles.toolbarButton,
pressed && editable ? styles.toolbarButtonPressed : null,
!editable ? styles.toolbarButtonDisabled : null
]}
>
<Icon size={15} color={editable ? colors.textPrimary : colors.textMuted} />
</Pressable>
)
})}
</ScrollView>
</View>
<MobileRichMarkdownToolbar editable={editable} onCommand={runCommand} />
<WebView
ref={webViewRef}
source={{ html, baseUrl: EDITOR_DOCUMENT_URL }}
@@ -228,32 +160,6 @@ const styles = StyleSheet.create({
minHeight: 0,
backgroundColor: colors.bgBase
},
toolbar: {
minHeight: 42,
borderBottomWidth: StyleSheet.hairlineWidth,
borderBottomColor: colors.borderSubtle,
backgroundColor: colors.bgPanel
},
toolbarContent: {
alignItems: 'center',
gap: 6,
paddingHorizontal: spacing.sm,
paddingVertical: 6
},
toolbarButton: {
minWidth: 30,
height: 30,
alignItems: 'center',
justifyContent: 'center',
borderRadius: radii.button,
paddingHorizontal: spacing.xs
},
toolbarButtonPressed: {
backgroundColor: colors.bgRaised
},
toolbarButtonDisabled: {
opacity: 0.55
},
webView: {
flex: 1,
minHeight: 0,
@@ -0,0 +1,116 @@
import { createElement } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, describe, expect, it, vi } from 'vitest'
vi.mock('react-native', async () => {
const React = await import('react')
return {
Pressable: 'Pressable',
ScrollView: ({ children, ...props }: { children?: unknown }) =>
React.createElement('ScrollView', props, children),
StyleSheet: { create: (styles: unknown) => styles, hairlineWidth: 1 },
View: 'View'
}
})
vi.mock('lucide-react-native', () => ({
Bold: 'Bold',
Code2: 'Code2',
FileCode2: 'FileCode2',
Heading1: 'Heading1',
Heading2: 'Heading2',
Heading3: 'Heading3',
ImageIcon: 'ImageIcon',
Italic: 'Italic',
Link: 'Link',
List: 'List',
ListOrdered: 'ListOrdered',
ListTodo: 'ListTodo',
Pilcrow: 'Pilcrow',
Quote: 'Quote',
Strikethrough: 'Strikethrough'
}))
import {
MOBILE_RICH_MARKDOWN_TOOLBAR_COMMANDS,
MobileRichMarkdownToolbar
} from './MobileRichMarkdownToolbar'
import type { MobileRichMarkdownCommand } from './mobile-rich-markdown-editor-contract'
/**
* The one row of controls both surfaces render.
*
* The WebView turns a press into an injected `runCommand` and the page turns it into a call, and
* neither difference belongs in the row. What is pinned here is the thing a second copy would have
* drifted on: that the row names every command the contract has, exactly once, so an editor whose
* document answers a command the toolbar cannot reach is a compile error rather than a control
* nobody has.
*/
const CONTRACT_COMMANDS: MobileRichMarkdownCommand[] = [
'paragraph',
'heading1',
'heading2',
'heading3',
'bold',
'italic',
'strike',
'bulletList',
'orderedList',
'taskList',
'quote',
'inlineCode',
'codeBlock',
'link',
'image'
]
let renderer: ReactTestRenderer | null = null
afterEach(() => {
act(() => renderer?.unmount())
renderer = null
})
function render(editable: boolean, onCommand: (command: MobileRichMarkdownCommand) => void) {
act(() => {
renderer = create(createElement(MobileRichMarkdownToolbar, { editable, onCommand }))
})
return renderer!.root.findAll((node) => node.type === 'Pressable')
}
describe('the rich Markdown toolbar', () => {
it('names every command in the contract, once', () => {
expect([...MOBILE_RICH_MARKDOWN_TOOLBAR_COMMANDS].sort()).toEqual([...CONTRACT_COMMANDS].sort())
expect(new Set(MOBILE_RICH_MARKDOWN_TOOLBAR_COMMANDS).size).toBe(15)
})
it('renders one labelled button per command and reports the press', () => {
const onCommand = vi.fn()
const buttons = render(true, onCommand)
expect(buttons).toHaveLength(15)
expect(buttons.map((button) => button.props.accessibilityLabel)).toEqual([
'Body',
'H1',
'H2',
'H3',
'Bold',
'Italic',
'Strike',
'Bullet list',
'Numbered list',
'Checklist',
'Quote',
'Link',
'Image',
'Inline code',
'Code block'
])
act(() => buttons[4]?.props.onPress())
expect(onCommand.mock.calls).toEqual([['bold']])
})
it('disables every button against a document that cannot be edited', () => {
const buttons = render(false, vi.fn())
expect(buttons.filter((button) => button.props.disabled !== true)).toEqual([])
})
})
@@ -0,0 +1,123 @@
import { memo, type ComponentType } from 'react'
import { Pressable, ScrollView, StyleSheet, View } from 'react-native'
import {
Bold,
Code2,
FileCode2,
Heading1,
Heading2,
Heading3,
ImageIcon,
Italic,
Link,
List,
ListOrdered,
ListTodo,
Pilcrow,
Quote,
Strikethrough
} from 'lucide-react-native'
import { colors, radii, spacing } from '../theme/mobile-theme'
import type { MobileRichMarkdownCommand } from './mobile-rich-markdown-editor-contract'
type ToolbarItem = {
command: MobileRichMarkdownCommand
label: string
icon: ComponentType<{ size?: number; color?: string }>
}
/**
* The fifteen commands, in the order they are pressed in.
*
* Shared rather than declared twice because both surfaces drive the same document: inside the
* WebView the press becomes an injected `runCommand` and on the page it is a call, but the row of
* controls is the same row and a command added to the contract has to appear on both.
*/
const TOOLBAR_ITEMS: ToolbarItem[] = [
{ command: 'paragraph', label: 'Body', icon: Pilcrow },
{ command: 'heading1', label: 'H1', icon: Heading1 },
{ command: 'heading2', label: 'H2', icon: Heading2 },
{ command: 'heading3', label: 'H3', icon: Heading3 },
{ command: 'bold', label: 'Bold', icon: Bold },
{ command: 'italic', label: 'Italic', icon: Italic },
{ command: 'strike', label: 'Strike', icon: Strikethrough },
{ command: 'bulletList', label: 'Bullet list', icon: List },
{ command: 'orderedList', label: 'Numbered list', icon: ListOrdered },
{ command: 'taskList', label: 'Checklist', icon: ListTodo },
{ command: 'quote', label: 'Quote', icon: Quote },
{ command: 'link', label: 'Link', icon: Link },
{ command: 'image', label: 'Image', icon: ImageIcon },
{ command: 'inlineCode', label: 'Inline code', icon: Code2 },
{ command: 'codeBlock', label: 'Code block', icon: FileCode2 }
]
/** The commands alone, for a caller that drives the row rather than renders it. */
export const MOBILE_RICH_MARKDOWN_TOOLBAR_COMMANDS = TOOLBAR_ITEMS.map((item) => item.command)
export const MobileRichMarkdownToolbar = memo(function MobileRichMarkdownToolbar({
editable,
onCommand
}: {
editable: boolean
onCommand: (command: MobileRichMarkdownCommand) => void
}) {
return (
<View style={styles.toolbar}>
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={styles.toolbarContent}
keyboardShouldPersistTaps="handled"
>
{TOOLBAR_ITEMS.map((item) => {
const Icon = item.icon
return (
<Pressable
key={item.command}
disabled={!editable}
accessibilityRole="button"
accessibilityLabel={item.label}
onPress={() => onCommand(item.command)}
style={({ pressed }) => [
styles.toolbarButton,
pressed && editable ? styles.toolbarButtonPressed : null,
!editable ? styles.toolbarButtonDisabled : null
]}
>
<Icon size={15} color={editable ? colors.textPrimary : colors.textMuted} />
</Pressable>
)
})}
</ScrollView>
</View>
)
})
const styles = StyleSheet.create({
toolbar: {
minHeight: 42,
borderBottomWidth: StyleSheet.hairlineWidth,
borderBottomColor: colors.borderSubtle,
backgroundColor: colors.bgPanel
},
toolbarContent: {
alignItems: 'center',
gap: 6,
paddingHorizontal: spacing.sm,
paddingVertical: 6
},
toolbarButton: {
minWidth: 30,
height: 30,
alignItems: 'center',
justifyContent: 'center',
borderRadius: radii.button,
paddingHorizontal: spacing.xs
},
toolbarButtonPressed: {
backgroundColor: colors.bgRaised
},
toolbarButtonDisabled: {
opacity: 0.55
}
})