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