diff --git a/mobile/src/components/MobileRichMarkdownEditor.tsx b/mobile/src/components/MobileRichMarkdownEditor.tsx index 022908bbb6e..197092b7159 100644 --- a/mobile/src/components/MobileRichMarkdownEditor.tsx +++ b/mobile/src/components/MobileRichMarkdownEditor.tsx @@ -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 ( - - - {TOOLBAR_ITEMS.map((item) => { - const Icon = item.icon - return ( - runCommand(item.command)} - style={({ pressed }) => [ - styles.toolbarButton, - pressed && editable ? styles.toolbarButtonPressed : null, - !editable ? styles.toolbarButtonDisabled : null - ]} - > - - - ) - })} - - + { + 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([]) + }) +}) diff --git a/mobile/src/components/MobileRichMarkdownToolbar.tsx b/mobile/src/components/MobileRichMarkdownToolbar.tsx new file mode 100644 index 00000000000..30edf8053f3 --- /dev/null +++ b/mobile/src/components/MobileRichMarkdownToolbar.tsx @@ -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 ( + + + {TOOLBAR_ITEMS.map((item) => { + const Icon = item.icon + return ( + onCommand(item.command)} + style={({ pressed }) => [ + styles.toolbarButton, + pressed && editable ? styles.toolbarButtonPressed : null, + !editable ? styles.toolbarButtonDisabled : null + ]} + > + + + ) + })} + + + ) +}) + +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 + } +})