diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 6b3ac5df17a..db8ef34889f 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -37,18 +37,31 @@ jobs: with: node-version: '20' cache: 'npm' + cache-dependency-path: | + package-lock.json + ui/mobile/package-lock.json - - name: Install dependencies + - name: Install root dependencies run: npm ci + - name: Install mobile dependencies + run: npm --prefix ui/mobile ci + - name: Setup Deno uses: denoland/setup-deno@v2 with: deno-version: v2.x - - name: Validate (type-check, lint, deno-check) + - name: Validate main project + id: validate_main + continue-on-error: true run: npm run validate + - name: Validate mobile package + id: validate_mobile + continue-on-error: true + run: npm --prefix ui/mobile run validate + - name: Run gitleaks scan if: always() uses: gitleaks/gitleaks-action@v2 @@ -74,3 +87,23 @@ jobs: echo "SHA: ${{ github.sha }}" echo "For Act local testing, use: build-${{ github.run_id }}" + - name: Fail if validation checks failed + if: always() + run: | + failed=0 + + if [ "${{ steps.validate_main.outcome }}" != "success" ]; then + echo "Main project validation failed." + failed=1 + fi + + if [ "${{ steps.validate_mobile.outcome }}" != "success" ]; then + echo "Mobile package validation failed." + failed=1 + fi + + if [ "$failed" -ne 0 ]; then + echo "One or more validation checks failed." + exit 1 + fi + diff --git a/docs/ai/implementation/feature-mobile-adaptation.md b/docs/ai/implementation/feature-mobile-adaptation.md index 643bbba195b..76437a97222 100644 --- a/docs/ai/implementation/feature-mobile-adaptation.md +++ b/docs/ai/implementation/feature-mobile-adaptation.md @@ -44,3 +44,12 @@ description: Technical implementation notes, patterns, and code guidelines for m **How do we keep it fast?** - Ensure the Sheet doesn't cause layout thrashing when opening. + +## Mobile Editor Toolbar + +- The mobile rich-text toolbar now groups advanced formatting into compact menus to keep the horizontal action row usable on phones. +- Heading selection uses a single trigger that opens `H1`/`H2`/`H3` actions. +- Text alignment uses a single trigger that opens `left`/`center`/`right` actions. +- Font size uses a compact size picker with the same point sizes as the web editor. +- Link editing and image insertion are handled through inline URL forms in the toolbar overlay. +- The React Native toolbar uses explicit bridge commands for `toggleHeadingLevel`, `clearFormatting`, `setLinkUrl`, and `insertImageUrl`; other actions still fall through the generic TipTap chain command lookup in the WebView editor. diff --git a/ui/mobile/app.config.ts b/ui/mobile/app.config.ts index 57667de17c0..42b65bb5b44 100644 --- a/ui/mobile/app.config.ts +++ b/ui/mobile/app.config.ts @@ -115,9 +115,9 @@ export default ({ config }: ConfigContext): ExpoConfig => { const editorWebViewOverride = variant === 'dev' ? devEditorWebViewUrl : '' const stageWebViewUrl = resolveStageWebViewUrl() const oauthRedirectOverride = (process.env.EXPO_PUBLIC_OAUTH_REDIRECT_URL ?? '').trim() - const resolvedEditorWebViewUrl = - editorWebViewOverride || - (variant === 'stage' && stageWebViewUrl ? stageWebViewUrl : variantConfig.editorWebViewUrl) + const resolvedEditorWebViewUrl = editorWebViewOverride !== '' + ? editorWebViewOverride + : (variant === 'stage' && stageWebViewUrl ? stageWebViewUrl : variantConfig.editorWebViewUrl) return { ...config, @@ -169,7 +169,7 @@ export default ({ config }: ConfigContext): ExpoConfig => { supabaseFunctionsUrl: variantConfig.supabaseFunctionsUrl, editorWebViewUrl: resolvedEditorWebViewUrl, requireEditorWebViewUrl: variantConfig.requireEditorWebViewUrl ?? false, - oauthRedirectUrl: oauthRedirectOverride || `${variantConfig.scheme}://auth/callback`, + oauthRedirectUrl: oauthRedirectOverride !== '' ? oauthRedirectOverride : `${variantConfig.scheme}://auth/callback`, }, } } diff --git a/ui/mobile/app/note/[id].tsx b/ui/mobile/app/note/[id].tsx index 6f9598ab6b4..97b8e98eb5b 100644 --- a/ui/mobile/app/note/[id].tsx +++ b/ui/mobile/app/note/[id].tsx @@ -27,6 +27,7 @@ export default function NoteEditorScreen() { const [title, setTitle] = useState('') const [tags, setTags] = useState([]) const [isEditorFocused, setIsEditorFocused] = useState(false) + const [isToolbarMenuOpen, setIsToolbarMenuOpen] = useState(false) const [hasSelection, setHasSelection] = useState(false) const [historyState, setHistoryState] = useState({ canUndo: false, canRedo: false }) const [keyboardHeight, setKeyboardHeight] = useState(0) @@ -158,7 +159,9 @@ export default function NoteEditorScreen() { }) }, [deleteNote, id, router]) - const editorPaddingBottom = isEditorFocused + const isToolbarVisible = isEditorFocused || isToolbarMenuOpen + + const editorPaddingBottom = isToolbarVisible ? keyboardHeight + TOOLBAR_CONTENT_HEIGHT + insets.bottom : Math.max(insets.bottom, 0) @@ -279,9 +282,13 @@ export default function NoteEditorScreen() { loadingFallback={} /> - {isEditorFocused && ( + {isToolbarVisible && ( - + )} diff --git a/ui/mobile/components/EditorToolbar.tsx b/ui/mobile/components/EditorToolbar.tsx index d70cd1d7157..8ffa624726b 100644 --- a/ui/mobile/components/EditorToolbar.tsx +++ b/ui/mobile/components/EditorToolbar.tsx @@ -1,117 +1,463 @@ -import React, { useMemo } from 'react' +import React, { useEffect, useMemo, useState } from 'react' import { ScrollView, Pressable, StyleSheet, Text, View } from 'react-native' import { useSafeAreaInsets } from 'react-native-safe-area-context' import { - Bold, - Italic, - Underline, - List, - ListOrdered, - Heading1, - Heading2, - Minus, - Quote, - Code + Bold, + Italic, + Strikethrough, + Underline, + Minus, + List, + ListOrdered, + Quote, + Code, } from 'lucide-react-native' import { useTheme } from '@ui/mobile/providers' +import { Button, Input } from '@ui/mobile/components/ui' export const TOOLBAR_CONTENT_HEIGHT = 48 type Props = { - onCommand: (method: string, args?: unknown[]) => void - hasSelection?: boolean + onCommand: (method: string, args?: unknown[]) => void + hasSelection?: boolean + onMenuVisibilityChange?: (visible: boolean) => void } -export const EditorToolbar = ({ onCommand, hasSelection = false }: Props) => { - const { colors } = useTheme() - const insets = useSafeAreaInsets() - const styles = useMemo(() => createStyles(colors), [colors]) +type MenuKey = 'heading' | 'align' | 'fontSize' | 'link' | 'image' | null - const ToolbarButton = ({ icon: Icon, onPress }: { icon: React.ElementType, onPress: () => void }) => ( - [ - styles.button, - pressed && styles.buttonPressed, - ]} - onPress={onPress} - > - - - ) +const FONT_SIZE_OPTIONS = ['10', '11', '12', '13', '14', '15', '18', '24', '30', '36'] + +type ToolbarStyles = ReturnType + +type ToolbarButtonProps = { + icon: React.ElementType + onPress: () => void + accessibilityLabel: string + styles: ToolbarStyles + iconColor: string +} + +const ToolbarButton = ({ icon: Icon, onPress, accessibilityLabel, styles, iconColor }: ToolbarButtonProps) => ( + [ + styles.button, + pressed && styles.buttonPressed, + ]} + onPress={onPress} + > + + +) + +type TextToolbarButtonProps = { + label: string + onPress: () => void + accessibilityLabel: string + active?: boolean + styles: ToolbarStyles +} + +const TextToolbarButton = ({ label, onPress, accessibilityLabel, active = false, styles }: TextToolbarButtonProps) => ( + [ + styles.textButton, + active && styles.textButtonActive, + pressed && styles.buttonPressed, + ]} + onPress={onPress} + > + {label} + +) + +export const EditorToolbar = ({ onCommand, hasSelection = false, onMenuVisibilityChange }: Props) => { + const { colors } = useTheme() + const insets = useSafeAreaInsets() + const styles = useMemo(() => createStyles(colors), [colors]) + const [activeMenu, setActiveMenu] = useState(null) + const [linkUrl, setLinkUrl] = useState('') + const [imageUrl, setImageUrl] = useState('') + + useEffect(() => { + if (activeMenu !== 'link') { + setLinkUrl('') + } + if (activeMenu !== 'image') { + setImageUrl('') + } + }, [activeMenu]) + + useEffect(() => { + onMenuVisibilityChange?.(activeMenu !== null) + }, [activeMenu, onMenuVisibilityChange]) + + const runCommand = (method: string, args?: unknown[]) => { + onCommand(method, args) + } + + const closeMenu = () => setActiveMenu(null) + + const toggleMenu = (menu: Exclude) => { + setActiveMenu((current) => (current === menu ? null : menu)) + } + + const openMenu = (menu: Exclude) => { + const isOpening = activeMenu !== menu + if (isOpening && (menu === 'link' || menu === 'image')) { + // Dismiss editor selection handles/native action menu before opening URL forms. + runCommand('blur') + } + toggleMenu(menu) + } + + const handleLinkApply = () => { + const normalized = linkUrl.trim() + if (!normalized) return + runCommand('setLinkUrl', [normalized]) + closeMenu() + } + + const handleLinkRemove = () => { + runCommand('setLinkUrl', ['']) + closeMenu() + } + + const handleImageApply = () => { + const normalized = imageUrl.trim() + if (!normalized) return + runCommand('insertImageUrl', [normalized]) + closeMenu() + } + + const renderMenu = () => { + if (activeMenu === null) return null + + if (activeMenu === 'heading') { + return ( + + Heading + + {[1, 2, 3].map((level) => ( + + ))} + + + ) + } + + if (activeMenu === 'align') { + return ( + + Alignment + + {[ + { label: 'Left', value: 'left' }, + { label: 'Center', value: 'center' }, + { label: 'Right', value: 'right' }, + ].map((option) => ( + + ))} + + + ) + } + + if (activeMenu === 'fontSize') { + return ( + + Font size + + {FONT_SIZE_OPTIONS.map((size) => ( + + ))} + + + ) + } + + if (activeMenu === 'link') { + return ( + + Link + + + + + + + + ) + } return ( - - - onCommand('toggleBold')} /> - onCommand('toggleItalic')} /> - onCommand('toggleUnderline')} /> - - onCommand('toggleHeading', [{ level: 1 }])} /> - onCommand('toggleHeading', [{ level: 2 }])} /> - onCommand('setHorizontalRule')} /> - - onCommand('toggleBulletList')} /> - onCommand('toggleOrderedList')} /> - - onCommand('toggleBlockquote')} /> - onCommand('toggleCodeBlock')} /> - - [ - styles.button, - pressed && hasSelection && styles.buttonPressed, - !hasSelection && styles.buttonDisabled, - ]} - onPress={() => onCommand('applySelectionAsMarkdown')} - > - MD - - + + Image URL + + + + + ) + } + + return ( + + {activeMenu && ( + + {renderMenu()} + + )} + + runCommand('toggleBold')} accessibilityLabel="Bold" styles={styles} iconColor={colors.foreground} /> + runCommand('toggleItalic')} accessibilityLabel="Italic" styles={styles} iconColor={colors.foreground} /> + runCommand('toggleStrike')} accessibilityLabel="Strikethrough" styles={styles} iconColor={colors.foreground} /> + runCommand('toggleUnderline')} accessibilityLabel="Underline" styles={styles} iconColor={colors.foreground} /> + + openMenu('heading')} + styles={styles} + /> + openMenu('align')} + styles={styles} + /> + openMenu('fontSize')} + styles={styles} + /> + { + closeMenu() + runCommand('clearFormatting') + }} + styles={styles} + /> + runCommand('setHorizontalRule')} accessibilityLabel="Horizontal rule" styles={styles} iconColor={colors.foreground} /> + + runCommand('toggleBulletList')} accessibilityLabel="Bullet list" styles={styles} iconColor={colors.foreground} /> + runCommand('toggleOrderedList')} accessibilityLabel="Ordered list" styles={styles} iconColor={colors.foreground} /> + runCommand('toggleTaskList')} + styles={styles} + /> + runCommand('toggleBlockquote')} accessibilityLabel="Blockquote" styles={styles} iconColor={colors.foreground} /> + runCommand('toggleCodeBlock')} accessibilityLabel="Code block" styles={styles} iconColor={colors.foreground} /> + + openMenu('link')} + styles={styles} + /> + openMenu('image')} + styles={styles} + /> + + [ + styles.button, + pressed && hasSelection && styles.buttonPressed, + !hasSelection && styles.buttonDisabled, + ]} + onPress={() => runCommand('applySelectionAsMarkdown')} + > + MD + + + + ) } const createStyles = (colors: ReturnType['colors']) => StyleSheet.create({ - container: { - backgroundColor: colors.background, - borderTopWidth: 1, - borderTopColor: colors.border, - }, - scrollContent: { - paddingHorizontal: 8, - alignItems: 'center', - height: TOOLBAR_CONTENT_HEIGHT, - }, - button: { - width: 40, - height: 40, - justifyContent: 'center', - alignItems: 'center', - marginHorizontal: 2, - borderRadius: 8, - }, - buttonPressed: { - backgroundColor: colors.accent, - }, - buttonDisabled: { - opacity: 0.35, - }, - mdLabel: { - fontSize: 13, - fontWeight: '600' as const, - color: colors.foreground, - }, - divider: { - width: 1, - height: 24, - backgroundColor: colors.border, - marginHorizontal: 8, - }, + container: { + backgroundColor: colors.background, + borderTopWidth: 1, + borderTopColor: colors.border, + }, + scrollContent: { + paddingHorizontal: 8, + alignItems: 'center', + height: TOOLBAR_CONTENT_HEIGHT, + }, + button: { + width: 40, + height: 40, + justifyContent: 'center', + alignItems: 'center', + marginHorizontal: 2, + borderRadius: 8, + }, + textButton: { + minWidth: 48, + height: 40, + justifyContent: 'center', + alignItems: 'center', + marginHorizontal: 2, + borderRadius: 8, + paddingHorizontal: 10, + }, + textButtonActive: { + backgroundColor: colors.accent, + borderWidth: 1, + borderColor: colors.border, + }, + textButtonLabel: { + fontSize: 12, + fontWeight: '600' as const, + color: colors.foreground, + }, + buttonPressed: { + backgroundColor: colors.accent, + }, + buttonDisabled: { + opacity: 0.35, + }, + mdLabel: { + fontSize: 13, + fontWeight: '600' as const, + color: colors.foreground, + }, + divider: { + width: 1, + height: 24, + backgroundColor: colors.border, + marginHorizontal: 8, + }, + menuContainer: { + position: 'absolute', + left: 8, + right: 8, + zIndex: 5, + }, + menuCard: { + backgroundColor: colors.card, + borderWidth: 1, + borderColor: colors.border, + borderRadius: 12, + padding: 12, + shadowColor: '#000', + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.12, + shadowRadius: 8, + elevation: 6, + }, + menuTitle: { + fontSize: 13, + fontFamily: 'Inter_600SemiBold', + color: colors.foreground, + marginBottom: 10, + }, + optionRow: { + flexDirection: 'row', + gap: 8, + }, + optionButton: { + flex: 1, + }, + optionGrid: { + flexDirection: 'row', + flexWrap: 'wrap', + gap: 8, + }, + optionGridButton: { + minWidth: 54, + }, + menuInput: { + marginBottom: 10, + }, + formActions: { + flexDirection: 'row', + justifyContent: 'flex-end', + gap: 8, + }, + formButton: { + minWidth: 84, + }, }) diff --git a/ui/mobile/tests/component/editorToolbar.test.tsx b/ui/mobile/tests/component/editorToolbar.test.tsx index 35eb9aa43e4..ca07a532770 100644 --- a/ui/mobile/tests/component/editorToolbar.test.tsx +++ b/ui/mobile/tests/component/editorToolbar.test.tsx @@ -9,6 +9,15 @@ jest.mock('@ui/mobile/providers', () => ({ background: '#ffffff', border: '#cccccc', accent: '#eeeeee', + card: '#ffffff', + primary: '#228822', + primaryForeground: '#ffffff', + secondary: '#f3f3f3', + secondaryForeground: '#000000', + mutedForeground: '#666666', + destructive: '#cc0000', + destructiveForeground: '#ffffff', + ring: '#116611', }, }), })) @@ -19,81 +28,212 @@ jest.mock('lucide-react-native', () => { return { Bold: icon('Bold'), Italic: icon('Italic'), + Strikethrough: icon('Strikethrough'), Underline: icon('Underline'), + Minus: icon('Minus'), List: icon('List'), ListOrdered: icon('ListOrdered'), - Heading1: icon('Heading1'), - Heading2: icon('Heading2'), - Minus: icon('Minus'), Quote: icon('Quote'), Code: icon('Code'), } }) -describe('EditorToolbar — MD button', () => { +describe('EditorToolbar', () => { it('renders the MD button', () => { - const { getByLabelText } = render( - - ) + const { getByLabelText } = render() expect(getByLabelText('Apply as Markdown')).toBeTruthy() }) - it('is disabled by default (hasSelection not provided)', () => { - const { getByLabelText } = render( - - ) - + it('keeps the MD button disabled without selection', () => { + const onCommand = jest.fn() + const { getByLabelText } = render() const button = getByLabelText('Apply as Markdown') + expect(button.props.accessibilityState?.disabled).toBe(true) + fireEvent.press(button) + expect(onCommand).not.toHaveBeenCalled() }) - it('is disabled when hasSelection=false', () => { - const { getByLabelText } = render( - - ) + it('enables and triggers the MD button with selection', () => { + const onCommand = jest.fn() + const { getByLabelText } = render() - const button = getByLabelText('Apply as Markdown') - expect(button.props.accessibilityState?.disabled).toBe(true) + fireEvent.press(getByLabelText('Apply as Markdown')) + + expect(onCommand).toHaveBeenCalledWith('applySelectionAsMarkdown', undefined) }) - it('is enabled when hasSelection=true', () => { - const { getByLabelText } = render( - - ) + it('opens heading menu and sends selected heading command', () => { + const onCommand = jest.fn() + const { getByLabelText, getByText } = render() - const button = getByLabelText('Apply as Markdown') - expect(button.props.accessibilityState?.disabled).toBe(false) + fireEvent.press(getByLabelText('Open heading menu')) + fireEvent.press(getByText('H3')) + + expect(onCommand).toHaveBeenCalledWith('toggleHeadingLevel', [3]) }) - it('calls onCommand("applySelectionAsMarkdown") when pressed with selection', () => { + it('opens alignment menu and sends align command', () => { const onCommand = jest.fn() - const { getByLabelText } = render( - - ) + const { getByLabelText, getByText } = render() - fireEvent.press(getByLabelText('Apply as Markdown')) + fireEvent.press(getByLabelText('Open alignment menu')) + fireEvent.press(getByText('Center')) - expect(onCommand).toHaveBeenCalledTimes(1) - expect(onCommand).toHaveBeenCalledWith('applySelectionAsMarkdown') + expect(onCommand).toHaveBeenCalledWith('setTextAlign', ['center']) }) - it('does not call onCommand when disabled (hasSelection=false)', () => { + it('opens font size menu and sends size command', () => { const onCommand = jest.fn() + const { getByLabelText, getByText } = render() + + fireEvent.press(getByLabelText('Open font size menu')) + fireEvent.press(getByText('24')) + + expect(onCommand).toHaveBeenCalledWith('setFontSize', ['24pt']) + }) + + it('sends clear formatting command', () => { + const onCommand = jest.fn() + const { getByLabelText, getByText } = render() + + expect(getByText('Clear')).toBeTruthy() + + fireEvent.press(getByLabelText('Clear formatting')) + + expect(onCommand).toHaveBeenCalledWith('clearFormatting', undefined) + }) + + it('blurs editor before opening link/image menus', () => { + const onCommand = jest.fn() + const { getByLabelText } = render() + + fireEvent.press(getByLabelText('Open link menu')) + fireEvent.press(getByLabelText('Open image menu')) + + expect(onCommand).toHaveBeenNthCalledWith(1, 'blur', undefined) + expect(onCommand).toHaveBeenNthCalledWith(2, 'blur', undefined) + }) + + it('reports menu visibility changes', () => { + const onMenuVisibilityChange = jest.fn() const { getByLabelText } = render( - + ) - fireEvent.press(getByLabelText('Apply as Markdown')) + expect(onMenuVisibilityChange).toHaveBeenLastCalledWith(false) + + fireEvent.press(getByLabelText('Open link menu')) + expect(onMenuVisibilityChange).toHaveBeenLastCalledWith(true) + + fireEvent.press(getByLabelText('Open link menu')) + expect(onMenuVisibilityChange).toHaveBeenLastCalledWith(false) + }) + + it('sends horizontal rule command', () => { + const onCommand = jest.fn() + const { getByLabelText } = render() + + fireEvent.press(getByLabelText('Horizontal rule')) + + expect(onCommand).toHaveBeenCalledWith('setHorizontalRule', undefined) + }) + + it('sends strikethrough command', () => { + const onCommand = jest.fn() + const { getByLabelText } = render() + + fireEvent.press(getByLabelText('Strikethrough')) + + expect(onCommand).toHaveBeenCalledWith('toggleStrike', undefined) + }) + + it('sends task list command', () => { + const onCommand = jest.fn() + const { getByLabelText } = render() + + fireEvent.press(getByLabelText('Task list')) + + expect(onCommand).toHaveBeenCalledWith('toggleTaskList', undefined) + }) + + it('sends one-tap formatting commands', () => { + const onCommand = jest.fn() + const { getByLabelText } = render() + + fireEvent.press(getByLabelText('Bold')) + fireEvent.press(getByLabelText('Italic')) + fireEvent.press(getByLabelText('Underline')) + fireEvent.press(getByLabelText('Bullet list')) + fireEvent.press(getByLabelText('Ordered list')) + fireEvent.press(getByLabelText('Blockquote')) + fireEvent.press(getByLabelText('Code block')) + + expect(onCommand).toHaveBeenNthCalledWith(1, 'toggleBold', undefined) + expect(onCommand).toHaveBeenNthCalledWith(2, 'toggleItalic', undefined) + expect(onCommand).toHaveBeenNthCalledWith(3, 'toggleUnderline', undefined) + expect(onCommand).toHaveBeenNthCalledWith(4, 'toggleBulletList', undefined) + expect(onCommand).toHaveBeenNthCalledWith(5, 'toggleOrderedList', undefined) + expect(onCommand).toHaveBeenNthCalledWith(6, 'toggleBlockquote', undefined) + expect(onCommand).toHaveBeenNthCalledWith(7, 'toggleCodeBlock', undefined) + }) + + it('applies a link from the link menu', () => { + const onCommand = jest.fn() + const { getByLabelText, getByPlaceholderText, getByText } = render() + + fireEvent.press(getByLabelText('Open link menu')) + fireEvent.changeText(getByPlaceholderText('https://example.com'), 'https://openai.com') + fireEvent.press(getByText('Apply')) + + expect(onCommand).toHaveBeenCalledWith('setLinkUrl', ['https://openai.com']) + }) + + it('removes a link from the link menu', () => { + const onCommand = jest.fn() + const { getByLabelText, getByText } = render() + + fireEvent.press(getByLabelText('Open link menu')) + fireEvent.press(getByText('Remove')) + + expect(onCommand).toHaveBeenCalledWith('setLinkUrl', ['']) + }) + + it('inserts an image from the image menu', () => { + const onCommand = jest.fn() + const { getByLabelText, getByPlaceholderText, getByText } = render() + + fireEvent.press(getByLabelText('Open image menu')) + fireEvent.changeText(getByPlaceholderText('https://example.com/image.png'), 'https://cdn.test/image.png') + fireEvent.press(getByText('Insert')) + + expect(onCommand).toHaveBeenCalledWith('insertImageUrl', ['https://cdn.test/image.png']) + }) + + it('closes link menu on cancel without calling onCommand', () => { + const onCommand = jest.fn() + const { getByLabelText, getByText, queryByPlaceholderText } = render() + + fireEvent.press(getByLabelText('Open link menu')) + onCommand.mockClear() + + fireEvent.press(getByText('Cancel')) expect(onCommand).not.toHaveBeenCalled() + expect(queryByPlaceholderText('https://example.com')).toBeNull() }) - it('displays "MD" label text', () => { - const { getByText } = render( - - ) + it('closes image menu on cancel without calling onCommand', () => { + const onCommand = jest.fn() + const { getByLabelText, getByText, queryByPlaceholderText } = render() + + fireEvent.press(getByLabelText('Open image menu')) + onCommand.mockClear() - expect(getByText('MD')).toBeTruthy() + fireEvent.press(getByText('Cancel')) + + expect(onCommand).not.toHaveBeenCalled() + expect(queryByPlaceholderText('https://example.com/image.png')).toBeNull() }) }) diff --git a/ui/mobile/tests/integration/noteEditorScreen.test.tsx b/ui/mobile/tests/integration/noteEditorScreen.test.tsx index f875df77c86..f76572877e4 100644 --- a/ui/mobile/tests/integration/noteEditorScreen.test.tsx +++ b/ui/mobile/tests/integration/noteEditorScreen.test.tsx @@ -119,16 +119,22 @@ jest.mock('@ui/mobile/services/sync', () => ({ const mockEditorCallbacks: { onContentChange?: (html: string) => void onBlur?: () => void + onFocus?: () => void +} = {} + +const mockToolbarCallbacks: { + onMenuVisibilityChange?: (visible: boolean) => void } = {} jest.mock('@ui/mobile/components/EditorWebView', () => { const React = require('react') const { View, Text } = require('react-native') - return React.forwardRef((props: { onContentChange?: (html: string) => void; onBlur?: () => void }, ref: unknown) => { + return React.forwardRef((props: { onContentChange?: (html: string) => void; onBlur?: () => void; onFocus?: () => void }, ref: unknown) => { // Store callbacks for test access mockEditorCallbacks.onContentChange = props.onContentChange mockEditorCallbacks.onBlur = props.onBlur + mockEditorCallbacks.onFocus = props.onFocus React.useImperativeHandle(ref, () => ({ runCommand: jest.fn(), @@ -143,8 +149,9 @@ jest.mock('@ui/mobile/components/EditorWebView', () => { }) jest.mock('@ui/mobile/components/EditorToolbar', () => ({ - EditorToolbar: () => { + EditorToolbar: ({ onMenuVisibilityChange }: { onMenuVisibilityChange?: (visible: boolean) => void }) => { const { View, Text } = require('react-native') + mockToolbarCallbacks.onMenuVisibilityChange = onMenuVisibilityChange return ( Toolbar @@ -189,6 +196,10 @@ describe('NoteEditorScreen - Delete Functionality', () => { beforeEach(() => { queryClient = createTestQueryClient() wrapper = createQueryWrapper(queryClient) + mockEditorCallbacks.onContentChange = undefined + mockEditorCallbacks.onBlur = undefined + mockEditorCallbacks.onFocus = undefined + mockToolbarCallbacks.onMenuVisibilityChange = undefined mockNoteService.prototype.getNote = jest.fn().mockResolvedValue({ id: 'test-note-id', @@ -518,6 +529,37 @@ describe('NoteEditorScreen - Delete Functionality', () => { }) }) + describe('Toolbar visibility', () => { + it('keeps toolbar visible while menu is open after editor blur', async () => { + render(, { wrapper }) + + await waitFor(() => { + expect(screen.queryByTestId('editor-webview')).toBeTruthy() + }) + + expect(screen.queryByTestId('editor-toolbar')).toBeNull() + + act(() => { + mockEditorCallbacks.onFocus?.() + }) + expect(screen.queryByTestId('editor-toolbar')).toBeTruthy() + + act(() => { + mockToolbarCallbacks.onMenuVisibilityChange?.(true) + }) + + act(() => { + mockEditorCallbacks.onBlur?.() + }) + expect(screen.queryByTestId('editor-toolbar')).toBeTruthy() + + act(() => { + mockToolbarCallbacks.onMenuVisibilityChange?.(false) + }) + expect(screen.queryByTestId('editor-toolbar')).toBeNull() + }) + }) + describe('Content save on blur', () => { it('flushes pending content save when editor loses focus', async () => { render(, { wrapper }) diff --git a/ui/web/components/RichTextEditor.tsx b/ui/web/components/RichTextEditor.tsx index 98f8f4befd8..949b6174d5a 100644 --- a/ui/web/components/RichTextEditor.tsx +++ b/ui/web/components/RichTextEditor.tsx @@ -15,6 +15,7 @@ import { applySelectionAsMarkdown } from "@ui/web/lib/editor" import { EditorMenuBar, type HistoryState } from "./EditorMenuBar" import { editorExtensions } from "./editorExtensions" import { CHUNK_FOCUS_KEY } from "@/extensions/ChunkFocus" +import { executeEditorCommand } from "./executeEditorCommand" export type RichTextEditorHandle = { getHTML: () => string @@ -336,18 +337,12 @@ const RichTextEditor = React.forwardRef { if (!editor) return - if (command === "undo") { - editor.commands.undo() - return - } - if (command === "redo") { - editor.commands.redo() - return - } - const cmd = (editor.chain().focus() as unknown as Record { run: () => void }>)[command] - if (typeof cmd === 'function') { - cmd(...args).run() - } + executeEditorCommand({ + editor, + command, + args, + onApplySelectionAsMarkdown: handleApplySelectionAsMarkdown, + }) }, scrollToChunk: (charOffset: number, chunkLength: number) => { if (!editor) { @@ -358,7 +353,7 @@ const RichTextEditor = React.forwardRef diff --git a/ui/web/components/RichTextEditorWebView.tsx b/ui/web/components/RichTextEditorWebView.tsx index ef9263f5d27..4ff1e152d68 100644 --- a/ui/web/components/RichTextEditorWebView.tsx +++ b/ui/web/components/RichTextEditorWebView.tsx @@ -8,6 +8,7 @@ import { editorExtensions } from "./editorExtensions" import { SmartPasteService } from "@core/services/smartPaste" import { placeCaretFromCoords } from "@core/utils/prosemirrorCaret" import { applySelectionAsMarkdown } from "@ui/web/lib/editor" +import { executeEditorCommand } from "./executeEditorCommand" export type RichTextEditorWebViewHandle = { getHTML: () => string @@ -153,27 +154,12 @@ const RichTextEditorWebView = React.forwardRef< }, runCommand: (command: string, ...args: unknown[]) => { if (!editor) return - if (command === "undo") { - editor.commands.undo() - return - } - if (command === "redo") { - editor.commands.redo() - return - } - if (command === 'applySelectionAsMarkdown') { - handleApplySelectionAsMarkdown() - return - } - const cmd = ( - editor.chain().focus() as unknown as Record< - string, - (...a: unknown[]) => { run: () => void } - > - )[command] - if (typeof cmd === "function") { - cmd(...args).run() - } + executeEditorCommand({ + editor, + command, + args, + onApplySelectionAsMarkdown: handleApplySelectionAsMarkdown, + }) }, }), [editor, handleApplySelectionAsMarkdown] diff --git a/ui/web/components/executeEditorCommand.ts b/ui/web/components/executeEditorCommand.ts new file mode 100644 index 00000000000..e573ee80e67 --- /dev/null +++ b/ui/web/components/executeEditorCommand.ts @@ -0,0 +1,94 @@ +import type { Editor } from "@tiptap/react" + +export type EditorCommand = + | "undo" + | "redo" + | "applySelectionAsMarkdown" + | "clearFormatting" + | "setLinkUrl" + | "insertImageUrl" + | "toggleHeadingLevel" + | string + +type ExecuteEditorCommandParams = { + editor: Editor + command: EditorCommand + args: unknown[] + onApplySelectionAsMarkdown: () => void +} + +export const executeEditorCommand = ({ + editor, + command, + args, + onApplySelectionAsMarkdown, +}: ExecuteEditorCommandParams) => { + if (command === "undo") { + editor.commands.undo() + return + } + + if (command === "redo") { + editor.commands.redo() + return + } + + if (command === "applySelectionAsMarkdown") { + onApplySelectionAsMarkdown() + return + } + + if (command === "clearFormatting") { + editor.chain().focus().unsetAllMarks().clearNodes().run() + return + } + + if (command === "setLinkUrl") { + if (typeof args[0] !== "string") { + return + } + const url = args[0].trim() + const chain = editor.chain().focus().extendMarkRange("link") + if (url) { + chain.setLink({ href: url }).run() + } else { + chain.unsetLink().run() + } + return + } + + if (command === "insertImageUrl") { + const url = typeof args[0] === "string" ? args[0].trim() : "" + if (url) { + editor.chain().focus().setImage({ src: url }).run() + } + return + } + + if (command === "toggleHeadingLevel") { + const level = Number(args[0]) + if ([1, 2, 3].includes(level)) { + editor.chain().focus().toggleHeading({ level: level as 1 | 2 | 3 }).run() + } + return + } + + const chain = editor.chain().focus() as Record + if (!Object.prototype.hasOwnProperty.call(chain, command)) { + return + } + + const candidate = chain[command] + if (typeof candidate !== "function") { + return + } + + const next = (candidate as (...a: unknown[]) => unknown).apply(chain, args) + if ( + typeof next === "object" && + next !== null && + typeof (next as { run?: unknown }).run === "function" + ) { + ;(next as { run: () => void }).run() + } +}