diff --git a/web/src/client/components/file-picker.ts b/web/src/client/components/file-picker.ts index a3193860..540d053b 100644 --- a/web/src/client/components/file-picker.ts +++ b/web/src/client/components/file-picker.ts @@ -117,6 +117,36 @@ export class FilePicker extends LitElement { this.handleFileClick(); } + /** + * Public method to open file picker for images only + */ + openImagePicker(): void { + if (!this.fileInput) { + this.createFileInput(); + } + + if (this.fileInput) { + this.fileInput.accept = 'image/*'; + this.fileInput.removeAttribute('capture'); + this.fileInput.click(); + } + } + + /** + * Public method to open camera for image capture + */ + openCamera(): void { + if (!this.fileInput) { + this.createFileInput(); + } + + if (this.fileInput) { + this.fileInput.accept = 'image/*'; + this.fileInput.capture = 'environment'; + this.fileInput.click(); + } + } + private async uploadFileToServer(file: File): Promise { this.uploading = true; this.uploadProgress = 0; @@ -199,7 +229,8 @@ export class FilePicker extends LitElement { } if (this.fileInput) { - // Remove capture attribute for general file selection + // Reset to allow all files and remove capture attribute for general file selection + this.fileInput.accept = '*/*'; this.fileInput.removeAttribute('capture'); this.fileInput.click(); } diff --git a/web/src/client/components/keyboard-capture-indicator.ts b/web/src/client/components/keyboard-capture-indicator.ts index d2dfab3d..00812947 100644 --- a/web/src/client/components/keyboard-capture-indicator.ts +++ b/web/src/client/components/keyboard-capture-indicator.ts @@ -131,10 +131,10 @@ export class KeyboardCaptureIndicator extends LitElement { // Use the same button styling as other header buttons const buttonClasses = ` - bg-bg-tertiary border border-border rounded-lg p-2 font-mono text-muted + bg-bg-tertiary border border-border rounded-lg p-2 font-mono transition-all duration-200 hover:text-primary hover:bg-surface-hover hover:border-primary hover:shadow-sm flex-shrink-0 - ${this.active ? 'text-primary border-primary' : ''} + ${this.active ? 'text-primary' : 'text-muted'} ${this.animating ? 'animating' : ''} `.trim(); diff --git a/web/src/client/components/session-view.ts b/web/src/client/components/session-view.ts index f84c1df0..d51e9ea6 100644 --- a/web/src/client/components/session-view.ts +++ b/web/src/client/components/session-view.ts @@ -1005,6 +1005,67 @@ export class SessionView extends LitElement { this.showImagePicker = false; } + private async handlePasteImage() { + // Try to paste image from clipboard + try { + const clipboardItems = await navigator.clipboard.read(); + + for (const clipboardItem of clipboardItems) { + const imageTypes = clipboardItem.types.filter((type) => type.startsWith('image/')); + + for (const imageType of imageTypes) { + const blob = await clipboardItem.getType(imageType); + const file = new File([blob], `pasted-image.${imageType.split('/')[1]}`, { + type: imageType, + }); + + await this.uploadFile(file); + logger.log(`Successfully pasted image from clipboard`); + return; + } + } + + // No image found in clipboard + logger.log('No image found in clipboard'); + this.dispatchEvent( + new CustomEvent('error', { + detail: 'No image found in clipboard', + bubbles: true, + composed: true, + }) + ); + } catch (error) { + logger.error('Failed to paste image from clipboard:', error); + this.dispatchEvent( + new CustomEvent('error', { + detail: 'Failed to access clipboard. Please check permissions.', + bubbles: true, + composed: true, + }) + ); + } + } + + private handleSelectImage() { + // Use the file picker component to open image picker + const filePicker = this.querySelector('file-picker') as FilePicker | null; + if (filePicker && typeof filePicker.openImagePicker === 'function') { + filePicker.openImagePicker(); + } else { + logger.error('File picker component not found or openImagePicker method not available'); + } + } + + private handleOpenCamera() { + // Use the file picker component to open camera + const filePicker = this.querySelector('file-picker') as FilePicker | null; + if (filePicker && typeof filePicker.openCamera === 'function') { + filePicker.openCamera(); + } else { + logger.error('File picker component not found or openCamera method not available'); + } + } + private async handleFileSelected(event: CustomEvent) { const { path } = event.detail; if (!path || !this.session) return; @@ -1377,6 +1438,10 @@ export class SessionView extends LitElement { this.customWidth = ''; }} @session-rename=${(e: CustomEvent) => this.handleRename(e)} + @paste-image=${() => this.handlePasteImage()} + @select-image=${() => this.handleSelectImage()} + @open-camera=${() => this.handleOpenCamera()} + @show-image-upload-options=${() => this.handleSelectImage()} @capture-toggled=${(e: CustomEvent) => { this.dispatchEvent( new CustomEvent('capture-toggled', { @@ -1638,6 +1703,7 @@ export class SessionView extends LitElement { @file-error=${this.handleFileError} @file-cancel=${this.handleCloseFilePicker} > + { + let element: ImageUploadMenu; + let mockCallbacks: { + onPasteImage: ReturnType; + onSelectImage: ReturnType; + onOpenCamera: ReturnType; + onBrowseFiles: ReturnType; + }; + + beforeEach(async () => { + // Reset clipboard API mock + vi.clearAllMocks(); + + // Create mock callbacks + mockCallbacks = { + onPasteImage: vi.fn(), + onSelectImage: vi.fn(), + onOpenCamera: vi.fn(), + onBrowseFiles: vi.fn(), + }; + + // Create element with callbacks + element = await fixture(html` + + `); + }); + + describe('Menu Toggle', () => { + it('should initially have menu closed', () => { + expect(element.shadowRoot).toBeNull(); // No shadow DOM + const dropdown = element.querySelector('[style*="z-index"]'); + expect(dropdown).toBeNull(); + }); + + it('should open menu when button is clicked', async () => { + const button = element.querySelector( + 'button[aria-label="Upload image menu"]' + ) as HTMLButtonElement; + expect(button).toBeTruthy(); + + button.click(); + await element.updateComplete; + + const dropdown = element.querySelector('[style*="z-index"]'); + expect(dropdown).toBeTruthy(); + expect(button.getAttribute('aria-expanded')).toBe('true'); + }); + + it('should close menu when button is clicked again', async () => { + const button = element.querySelector( + 'button[aria-label="Upload image menu"]' + ) as HTMLButtonElement; + + // Open menu + button.click(); + await element.updateComplete; + + // Close menu + button.click(); + await element.updateComplete; + + const dropdown = element.querySelector('[style*="z-index"]'); + expect(dropdown).toBeNull(); + expect(button.getAttribute('aria-expanded')).toBe('false'); + }); + + it('should apply active styles when menu is open', async () => { + const button = element.querySelector( + 'button[aria-label="Upload image menu"]' + ) as HTMLButtonElement; + + button.click(); + await element.updateComplete; + + expect(button.className).toContain('bg-surface-hover'); + expect(button.className).toContain('border-primary'); + expect(button.className).toContain('text-primary'); + }); + }); + + describe('Clipboard Detection', () => { + it('should show paste option when clipboard has image', async () => { + // Mock clipboard API with image + const mockClipboardItem = { + types: ['image/png'], + }; + + Object.defineProperty(navigator, 'clipboard', { + value: { + read: vi.fn().mockResolvedValue([mockClipboardItem]), + }, + configurable: true, + }); + + // Open menu + const button = element.querySelector( + 'button[aria-label="Upload image menu"]' + ) as HTMLButtonElement; + button.click(); + await element.updateComplete; + + // Wait for clipboard check + await new Promise((resolve) => setTimeout(resolve, 100)); + await element.updateComplete; + + const pasteButton = element.querySelector('button[data-action="paste"]'); + expect(pasteButton).toBeTruthy(); + expect(pasteButton?.textContent).toContain('Paste from Clipboard'); + }); + + it('should not show paste option when clipboard has no image', async () => { + // Mock clipboard API without image + const mockClipboardItem = { + types: ['text/plain'], + }; + + Object.defineProperty(navigator, 'clipboard', { + value: { + read: vi.fn().mockResolvedValue([mockClipboardItem]), + }, + configurable: true, + }); + + // Open menu + const button = element.querySelector( + 'button[aria-label="Upload image menu"]' + ) as HTMLButtonElement; + button.click(); + await element.updateComplete; + + // Wait for clipboard check + await new Promise((resolve) => setTimeout(resolve, 100)); + await element.updateComplete; + + const pasteButton = element.querySelector('button[data-action="paste"]'); + expect(pasteButton).toBeNull(); + }); + + it('should handle clipboard API errors gracefully', async () => { + // Mock clipboard API to throw error + Object.defineProperty(navigator, 'clipboard', { + value: { + read: vi.fn().mockRejectedValue(new Error('Permission denied')), + }, + configurable: true, + }); + + // Open menu + const button = element.querySelector( + 'button[aria-label="Upload image menu"]' + ) as HTMLButtonElement; + button.click(); + await element.updateComplete; + + // Wait for clipboard check + await new Promise((resolve) => setTimeout(resolve, 100)); + await element.updateComplete; + + // Should not show paste option when clipboard check fails + const pasteButton = element.querySelector('button[data-action="paste"]'); + expect(pasteButton).toBeNull(); + }); + }); + + describe('Menu Items', () => { + beforeEach(async () => { + // Open menu for all menu item tests + const button = element.querySelector( + 'button[aria-label="Upload image menu"]' + ) as HTMLButtonElement; + button.click(); + await element.updateComplete; + }); + + it('should always show Select Image option', () => { + const selectButton = element.querySelector('button[data-action="select"]'); + expect(selectButton).toBeTruthy(); + expect(selectButton?.textContent).toContain('Select Image'); + expect(selectButton?.getAttribute('aria-label')).toBe('Select image from device'); + }); + + it('should always show Browse Files option', () => { + const browseButton = element.querySelector('button[data-action="browse"]'); + expect(browseButton).toBeTruthy(); + expect(browseButton?.textContent).toContain('Browse Files'); + expect(browseButton?.getAttribute('aria-label')).toBe('Browse files on device'); + }); + + it('should show Camera option only on mobile devices with cameras', async () => { + // Test desktop (default) - should not show camera + let cameraButton = element.querySelector('button[data-action="camera"]'); + expect(cameraButton).toBeNull(); + + // Set mobile mode but no camera - should still not show + element.isMobile = true; + element.hasCamera = false; + await element.updateComplete; + + cameraButton = element.querySelector('button[data-action="camera"]'); + expect(cameraButton).toBeNull(); + + // Set both mobile mode and hasCamera - should now show + element.hasCamera = true; + await element.updateComplete; + + cameraButton = element.querySelector('button[data-action="camera"]'); + expect(cameraButton).toBeTruthy(); + expect(cameraButton?.textContent).toContain('Camera'); + expect(cameraButton?.getAttribute('aria-label')).toBe('Take photo with camera'); + + // Test desktop with camera - should not show + element.isMobile = false; + element.hasCamera = true; + await element.updateComplete; + + cameraButton = element.querySelector('button[data-action="camera"]'); + expect(cameraButton).toBeNull(); + }); + + it('should show divider only when there are items above Browse Files', async () => { + // Initially no divider (no paste, no camera on desktop) + let divider = element.querySelector('.border-t.border-border'); + expect(divider).toBeNull(); + + // Set mobile mode but no camera - still no divider + element.isMobile = true; + element.hasCamera = false; + await element.updateComplete; + + divider = element.querySelector('.border-t.border-border'); + expect(divider).toBeNull(); + + // Set both mobile mode and hasCamera - should now have divider + element.hasCamera = true; + await element.updateComplete; + + divider = element.querySelector('.border-t.border-border'); + expect(divider).toBeTruthy(); + }); + }); + + describe('Action Callbacks', () => { + beforeEach(async () => { + // Open menu + const button = element.querySelector( + 'button[aria-label="Upload image menu"]' + ) as HTMLButtonElement; + button.click(); + await element.updateComplete; + }); + + it('should call onSelectImage callback and close menu', async () => { + const selectButton = element.querySelector( + 'button[data-action="select"]' + ) as HTMLButtonElement; + selectButton.click(); + + // Wait for animation delay + await new Promise((resolve) => setTimeout(resolve, 60)); + + expect(mockCallbacks.onSelectImage).toHaveBeenCalledOnce(); + + // Menu should be closed + await element.updateComplete; + const dropdown = element.querySelector('[style*="z-index"]'); + expect(dropdown).toBeNull(); + }); + + it('should call onBrowseFiles callback and close menu', async () => { + const browseButton = element.querySelector( + 'button[data-action="browse"]' + ) as HTMLButtonElement; + browseButton.click(); + + // Wait for animation delay + await new Promise((resolve) => setTimeout(resolve, 60)); + + expect(mockCallbacks.onBrowseFiles).toHaveBeenCalledOnce(); + + // Menu should be closed + await element.updateComplete; + const dropdown = element.querySelector('[style*="z-index"]'); + expect(dropdown).toBeNull(); + }); + + it('should call onOpenCamera callback on mobile with camera', async () => { + element.isMobile = true; + element.hasCamera = true; + await element.updateComplete; + + const cameraButton = element.querySelector( + 'button[data-action="camera"]' + ) as HTMLButtonElement; + cameraButton.click(); + + // Wait for animation delay + await new Promise((resolve) => setTimeout(resolve, 60)); + + expect(mockCallbacks.onOpenCamera).toHaveBeenCalledOnce(); + }); + }); + + describe('Keyboard Navigation', () => { + beforeEach(async () => { + // Open menu + const button = element.querySelector( + 'button[aria-label="Upload image menu"]' + ) as HTMLButtonElement; + button.click(); + await element.updateComplete; + }); + + it('should close menu on Escape key', async () => { + const event = new KeyboardEvent('keydown', { key: 'Escape' }); + document.dispatchEvent(event); + + await element.updateComplete; + + const dropdown = element.querySelector('[style*="z-index"]'); + expect(dropdown).toBeNull(); + }); + + it('should navigate menu items with arrow keys', async () => { + // Press down arrow + let event = new KeyboardEvent('keydown', { key: 'ArrowDown' }); + document.dispatchEvent(event); + await element.updateComplete; + + // First item should be focused + const buttons = element.querySelectorAll('button[data-action]'); + expect(buttons[0]?.className).toContain('bg-secondary'); + + // Press down arrow again + event = new KeyboardEvent('keydown', { key: 'ArrowDown' }); + document.dispatchEvent(event); + await element.updateComplete; + + // Second item should be focused + expect(buttons[1]?.className).toContain('bg-secondary'); + + // Press up arrow + event = new KeyboardEvent('keydown', { key: 'ArrowUp' }); + document.dispatchEvent(event); + await element.updateComplete; + + // First item should be focused again + expect(buttons[0]?.className).toContain('bg-secondary'); + }); + + it('should select focused item on Enter key', async () => { + // Navigate to first item + const downEvent = new KeyboardEvent('keydown', { key: 'ArrowDown' }); + document.dispatchEvent(downEvent); + await element.updateComplete; + + // Press Enter + const enterEvent = new KeyboardEvent('keydown', { key: 'Enter' }); + document.dispatchEvent(enterEvent); + + // Wait for animation delay + await new Promise((resolve) => setTimeout(resolve, 60)); + + // First action should have been called + expect(mockCallbacks.onSelectImage).toHaveBeenCalledOnce(); + }); + + it('should focus first item when pressing down arrow on menu button', async () => { + // Close menu first + const button = element.querySelector( + 'button[aria-label="Upload image menu"]' + ) as HTMLButtonElement; + button.click(); + await element.updateComplete; + + // Reopen menu + button.click(); + await element.updateComplete; + + // Focus the button + button.focus(); + + // Press down arrow on the button + const event = new KeyboardEvent('keydown', { + key: 'ArrowDown', + bubbles: true, + }); + button.dispatchEvent(event); + await element.updateComplete; + + // First menu item should be focused + const firstButton = element.querySelector('button[data-action]'); + expect(firstButton?.className).toContain('bg-secondary'); + }); + }); + + describe('Outside Click', () => { + it('should close menu when clicking outside', async () => { + // Open menu + const button = element.querySelector( + 'button[aria-label="Upload image menu"]' + ) as HTMLButtonElement; + button.click(); + await element.updateComplete; + + // Click outside + document.body.click(); + await element.updateComplete; + + const dropdown = element.querySelector('[style*="z-index"]'); + expect(dropdown).toBeNull(); + }); + + it('should not close menu when clicking inside dropdown', async () => { + // Open menu + const button = element.querySelector( + 'button[aria-label="Upload image menu"]' + ) as HTMLButtonElement; + button.click(); + await element.updateComplete; + + // Click inside dropdown + const dropdown = element.querySelector('[style*="z-index"]') as HTMLElement; + dropdown.click(); + await element.updateComplete; + + // Menu should still be open + const dropdownAfter = element.querySelector('[style*="z-index"]'); + expect(dropdownAfter).toBeTruthy(); + }); + }); + + describe('Accessibility', () => { + it('should have proper ARIA attributes on menu button', () => { + const button = element.querySelector( + 'button[aria-label="Upload image menu"]' + ) as HTMLButtonElement; + expect(button.getAttribute('aria-label')).toBe('Upload image menu'); + expect(button.getAttribute('aria-expanded')).toBe('false'); + }); + + it('should update aria-expanded when menu opens', async () => { + const button = element.querySelector( + 'button[aria-label="Upload image menu"]' + ) as HTMLButtonElement; + button.click(); + await element.updateComplete; + + expect(button.getAttribute('aria-expanded')).toBe('true'); + }); + + it('should have aria-labels on all menu items', async () => { + element.isMobile = true; + element.hasCamera = true; + await element.updateComplete; + + const button = element.querySelector( + 'button[aria-label="Upload image menu"]' + ) as HTMLButtonElement; + button.click(); + await element.updateComplete; + + const menuButtons = element.querySelectorAll('button[data-action]'); + menuButtons.forEach((btn) => { + expect(btn.getAttribute('aria-label')).toBeTruthy(); + }); + }); + + it('should have aria-hidden on decorative icons', async () => { + const button = element.querySelector( + 'button[aria-label="Upload image menu"]' + ) as HTMLButtonElement; + button.click(); + await element.updateComplete; + + const icons = element.querySelectorAll('svg[aria-hidden="true"]'); + expect(icons.length).toBeGreaterThan(0); + }); + }); + + describe('Component Cleanup', () => { + it('should remove event listeners on disconnect', () => { + const removeEventListenerSpy = vi.spyOn(document, 'removeEventListener'); + + element.disconnectedCallback(); + + expect(removeEventListenerSpy).toHaveBeenCalledWith('click', expect.any(Function)); + expect(removeEventListenerSpy).toHaveBeenCalledWith('keydown', expect.any(Function)); + }); + + it('should close menu on disconnect if open', async () => { + // Open menu + const button = element.querySelector( + 'button[aria-label="Upload image menu"]' + ) as HTMLButtonElement; + button.click(); + await element.updateComplete; + + // Verify menu is open + let dropdown = element.querySelector('[style*="z-index"]'); + expect(dropdown).toBeTruthy(); + + // Disconnect + element.disconnectedCallback(); + await element.updateComplete; + + // Menu should be closed + dropdown = element.querySelector('[style*="z-index"]'); + expect(dropdown).toBeNull(); + }); + }); + + describe('Camera Detection', () => { + it('should detect camera availability', async () => { + // Mock mediaDevices API + const mockDevices = [{ kind: 'videoinput', deviceId: 'camera1', label: 'Camera 1' }]; + + Object.defineProperty(navigator, 'mediaDevices', { + value: { + enumerateDevices: vi.fn().mockResolvedValue(mockDevices), + }, + configurable: true, + }); + + // Create new element to trigger camera check + const newElement = await fixture(html` + + `); + + // Wait for camera check + await new Promise((resolve) => setTimeout(resolve, 100)); + await newElement.updateComplete; + + // Open menu + const button = newElement.querySelector( + 'button[aria-label="Upload image menu"]' + ) as HTMLButtonElement; + button.click(); + await newElement.updateComplete; + + // Camera option should be visible + const cameraButton = newElement.querySelector('button[data-action="camera"]'); + expect(cameraButton).toBeTruthy(); + }); + + it('should handle camera detection errors', async () => { + // Mock mediaDevices API to throw error + Object.defineProperty(navigator, 'mediaDevices', { + value: { + enumerateDevices: vi.fn().mockRejectedValue(new Error('Permission denied')), + }, + configurable: true, + }); + + // Create new element to trigger camera check + const newElement = await fixture(html` + + `); + + // Wait for camera check + await new Promise((resolve) => setTimeout(resolve, 100)); + await newElement.updateComplete; + + // Should still render without errors + expect(newElement).toBeTruthy(); + }); + }); +}); diff --git a/web/src/client/components/session-view/image-upload-menu.ts b/web/src/client/components/session-view/image-upload-menu.ts new file mode 100644 index 00000000..c5703405 --- /dev/null +++ b/web/src/client/components/session-view/image-upload-menu.ts @@ -0,0 +1,304 @@ +/** + * Image Upload Menu Component + * + * Provides a dropdown menu for various image upload options including + * paste, file selection, camera access, and file browsing. + */ +import { html, LitElement, nothing } from 'lit'; +import { customElement, property, state } from 'lit/decorators.js'; +import { Z_INDEX } from '../../utils/constants.js'; + +// Delay to ensure menu close animation completes before action +const MENU_CLOSE_ANIMATION_DELAY = 50; + +@customElement('image-upload-menu') +export class ImageUploadMenu extends LitElement { + // Disable shadow DOM to use Tailwind + createRenderRoot() { + return this; + } + + @property({ type: Function }) onPasteImage?: () => void; + @property({ type: Function }) onSelectImage?: () => void; + @property({ type: Function }) onOpenCamera?: () => void; + @property({ type: Function }) onBrowseFiles?: () => void; + @property({ type: Boolean }) isMobile = false; + @property({ type: Boolean }) hasCamera = false; + + @state() private showMenu = false; + @state() private focusedIndex = -1; + @state() private hasClipboardImage = false; + + private toggleMenu(e: Event) { + e.stopPropagation(); + this.showMenu = !this.showMenu; + if (!this.showMenu) { + this.focusedIndex = -1; + } else { + // Check clipboard when menu opens + this.checkClipboardContent(); + } + } + + private handleAction(callback?: () => void) { + if (callback) { + // Close menu immediately + this.showMenu = false; + this.focusedIndex = -1; + // Call the callback after a brief delay to ensure menu is closed + setTimeout(() => { + callback(); + }, MENU_CLOSE_ANIMATION_DELAY); + } + } + + connectedCallback() { + super.connectedCallback(); + // Close menu when clicking outside + document.addEventListener('click', this.handleOutsideClick); + // Add keyboard support + document.addEventListener('keydown', this.handleKeyDown); + // Check if device has camera + this.checkCameraAvailability(); + } + + disconnectedCallback() { + super.disconnectedCallback(); + // Always clean up event listeners + document.removeEventListener('click', this.handleOutsideClick); + document.removeEventListener('keydown', this.handleKeyDown); + // Close menu if it's open when component is disconnected + if (this.showMenu) { + this.showMenu = false; + this.focusedIndex = -1; + } + } + + private async checkCameraAvailability() { + try { + const devices = await navigator.mediaDevices.enumerateDevices(); + this.hasCamera = devices.some((device) => device.kind === 'videoinput'); + } catch { + this.hasCamera = false; + } + } + + private async checkClipboardContent() { + try { + // Check if clipboard API is available and we have permission + if (!navigator.clipboard || !navigator.clipboard.read) { + this.hasClipboardImage = false; + return; + } + + const clipboardItems = await navigator.clipboard.read(); + for (const item of clipboardItems) { + // Check if any of the types are image types + const hasImage = item.types.some((type) => type.startsWith('image/')); + if (hasImage) { + this.hasClipboardImage = true; + return; + } + } + this.hasClipboardImage = false; + } catch { + // If we can't access clipboard (no permission or other error), assume no image + this.hasClipboardImage = false; + } + } + + private handleOutsideClick = (e: MouseEvent) => { + const path = e.composedPath(); + if (!path.includes(this)) { + this.showMenu = false; + this.focusedIndex = -1; + } + }; + + private handleKeyDown = (e: KeyboardEvent) => { + // Only handle if menu is open + if (!this.showMenu) return; + + if (e.key === 'Escape') { + e.preventDefault(); + this.showMenu = false; + this.focusedIndex = -1; + // Focus the menu button + const button = this.querySelector( + 'button[aria-label="Upload image menu"]' + ) as HTMLButtonElement; + button?.focus(); + } else if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { + e.preventDefault(); + this.navigateMenu(e.key === 'ArrowDown' ? 1 : -1); + } else if (e.key === 'Enter' && this.focusedIndex >= 0) { + e.preventDefault(); + this.selectFocusedItem(); + } + }; + + private navigateMenu(direction: number) { + const menuItems = this.getMenuItems(); + if (menuItems.length === 0) return; + + // Calculate new index + let newIndex = this.focusedIndex + direction; + + // Handle wrapping + if (newIndex < 0) { + newIndex = menuItems.length - 1; + } else if (newIndex >= menuItems.length) { + newIndex = 0; + } + + this.focusedIndex = newIndex; + + // Focus the element + const focusedItem = menuItems[newIndex]; + if (focusedItem) { + focusedItem.focus(); + } + } + + private getMenuItems(): HTMLButtonElement[] { + if (!this.showMenu) return []; + + // Find all menu buttons + const buttons = Array.from(this.querySelectorAll('button[data-action]')) as HTMLButtonElement[]; + + return buttons.filter((btn) => btn.tagName === 'BUTTON'); + } + + private selectFocusedItem() { + const menuItems = this.getMenuItems(); + const focusedItem = menuItems[this.focusedIndex]; + if (focusedItem) { + focusedItem.click(); + } + } + + private getAvailableMenuItems() { + const items = []; + if (this.hasClipboardImage) { + items.push({ + id: 'paste', + label: 'Paste from Clipboard', + ariaLabel: 'Paste image from clipboard', + action: () => this.handleAction(this.onPasteImage), + icon: html``, + }); + } + items.push({ + id: 'select', + label: 'Select Image', + ariaLabel: 'Select image from device', + action: () => this.handleAction(this.onSelectImage), + icon: html``, + }); + if (this.isMobile && this.hasCamera) { + items.push({ + id: 'camera', + label: 'Camera', + ariaLabel: 'Take photo with camera', + action: () => this.handleAction(this.onOpenCamera), + icon: html``, + }); + } + if (this.hasClipboardImage || (this.isMobile && this.hasCamera)) { + items.push({ id: 'divider', isDivider: true }); + } + items.push({ + id: 'browse', + label: 'Browse Files', + ariaLabel: 'Browse files on device', + action: () => this.handleAction(this.onBrowseFiles), + icon: html``, + }); + return items; + } + + private handleMenuButtonKeyDown = (e: KeyboardEvent) => { + if (e.key === 'ArrowDown' && this.showMenu) { + e.preventDefault(); + // Focus first menu item when pressing down on the menu button + this.focusedIndex = 0; + const menuItems = this.getMenuItems(); + if (menuItems[0]) { + menuItems[0].focus(); + } + } + }; + + render() { + return html` +
+ + + + + ${this.showMenu ? this.renderDropdown() : nothing} +
+ `; + } + + private renderDropdown() { + // Use immutable index tracking for menu items + const menuItems = this.getAvailableMenuItems(); + let buttonIndex = 0; + + return html` +
+ ${menuItems.map((item) => { + if (item.isDivider) { + return html`
`; + } + const currentIndex = buttonIndex++; + return html` + + `; + })} +
+ `; + } +} diff --git a/web/src/client/components/session-view/mobile-menu.ts b/web/src/client/components/session-view/mobile-menu.ts index 436b939e..93f54e6d 100644 --- a/web/src/client/components/session-view/mobile-menu.ts +++ b/web/src/client/components/session-view/mobile-menu.ts @@ -22,6 +22,7 @@ export class MobileMenu extends LitElement { @property({ type: String }) widthTooltip = ''; @property({ type: Function }) onCreateSession?: () => void; @property({ type: Function }) onOpenFileBrowser?: () => void; + @property({ type: Function }) onUploadImage?: () => void; @property({ type: Function }) onMaxWidthToggle?: () => void; @property({ type: Function }) onOpenSettings?: () => void; @property({ type: String }) currentTheme: Theme = 'system'; @@ -260,6 +261,19 @@ export class MobileMenu extends LitElement { Browse Files + + + + + this.handlePasteImage()} + .onSelectImage=${() => this.handleSelectImage()} + .onOpenCamera=${() => this.handleOpenCamera()} + .onBrowseFiles=${() => this.onOpenFileBrowser?.()} + .isMobile=${this.isMobile} + > +