Add image upload menu with paste, select, camera, and browse options (#432)

This commit is contained in:
Peter Steinberger 2025-07-20 19:46:49 +02:00 committed by GitHub
parent 3aa63bd637
commit 690ac8fe48
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 1051 additions and 22 deletions

View file

@ -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<void> {
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();
}

View file

@ -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();

View file

@ -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}
></file-picker>
<!-- Width Selector Modal (moved here for proper positioning) -->
<terminal-settings-modal

View file

@ -0,0 +1,579 @@
// @vitest-environment happy-dom
import { fixture, html } from '@open-wc/testing';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { ImageUploadMenu } from './image-upload-menu.js';
import './image-upload-menu.js';
describe('ImageUploadMenu', () => {
let element: ImageUploadMenu;
let mockCallbacks: {
onPasteImage: ReturnType<typeof vi.fn>;
onSelectImage: ReturnType<typeof vi.fn>;
onOpenCamera: ReturnType<typeof vi.fn>;
onBrowseFiles: ReturnType<typeof vi.fn>;
};
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`
<image-upload-menu
.onPasteImage=${mockCallbacks.onPasteImage}
.onSelectImage=${mockCallbacks.onSelectImage}
.onOpenCamera=${mockCallbacks.onOpenCamera}
.onBrowseFiles=${mockCallbacks.onBrowseFiles}
></image-upload-menu>
`);
});
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`
<image-upload-menu .isMobile=${true}></image-upload-menu>
`);
// 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`
<image-upload-menu .isMobile=${true}></image-upload-menu>
`);
// Wait for camera check
await new Promise((resolve) => setTimeout(resolve, 100));
await newElement.updateComplete;
// Should still render without errors
expect(newElement).toBeTruthy();
});
});
});

View file

@ -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`<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
<path d="M5.75 1a.75.75 0 00-.75.75v3c0 .414.336.75.75.75h4.5a.75.75 0 00.75-.75v-3a.75.75 0 00-.75-.75h-4.5zM6.5 4V2.5h3V4h-3z"/>
<path d="M1.75 5a.75.75 0 00-.75.75v8.5c0 .414.336.75.75.75h12.5a.75.75 0 00.75-.75v-8.5a.75.75 0 00-.75-.75H11v1.5h2.5v6.5h-11v-6.5H5V5H1.75z"/>
<path d="M8.5 9.5a.5.5 0 10-1 0V11H6a.5.5 0 000 1h1.5v1.5a.5.5 0 001 0V12H10a.5.5 0 000-1H8.5V9.5z"/>
</svg>`,
});
}
items.push({
id: 'select',
label: 'Select Image',
ariaLabel: 'Select image from device',
action: () => this.handleAction(this.onSelectImage),
icon: html`<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
<path d="M14.5 2h-13C.67 2 0 2.67 0 3.5v9c0 .83.67 1.5 1.5 1.5h13c.83 0 1.5-.67 1.5-1.5v-9c0-.83-.67-1.5-1.5-1.5zM5.5 5a1.5 1.5 0 110 3 1.5 1.5 0 010-3zM13 11H3l2.5-3L7 10l2.5-3L13 11z"/>
</svg>`,
});
if (this.isMobile && this.hasCamera) {
items.push({
id: 'camera',
label: 'Camera',
ariaLabel: 'Take photo with camera',
action: () => this.handleAction(this.onOpenCamera),
icon: html`<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
<path d="M10.5 2.5a.5.5 0 00-.5-.5H6a.5.5 0 00-.5.5V3H3a2 2 0 00-2 2v6a2 2 0 002 2h10a2 2 0 002-2V5a2 2 0 00-2-2h-2.5v-.5zM6.5 3h3v.5h-3V3zM13 4a1 1 0 011 1v6a1 1 0 01-1 1H3a1 1 0 01-1-1V5a1 1 0 011-1h10z"/>
<path d="M8 5.5a2.5 2.5 0 100 5 2.5 2.5 0 000-5zM6 8a2 2 0 114 0 2 2 0 01-4 0z"/>
</svg>`,
});
}
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`<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
<path d="M1.75 1h5.5c.966 0 1.75.784 1.75 1.75v1h4c.966 0 1.75.784 1.75 1.75v7.75A1.75 1.75 0 0113 15H3a1.75 1.75 0 01-1.75-1.75V2.75C1.25 1.784 1.784 1 1.75 1zM2.75 2.5v10.75c0 .138.112.25.25.25h10a.25.25 0 00.25-.25V5.5a.25.25 0 00-.25-.25H8.75v-2.5a.25.25 0 00-.25-.25h-5.5a.25.25 0 00-.25.25z"/>
</svg>`,
});
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`
<div class="relative">
<vt-tooltip content="Upload Image (⌘U)" .show=${!this.isMobile}>
<button
class="${
this.showMenu
? 'bg-surface-hover border-primary text-primary shadow-sm'
: 'bg-bg-tertiary border-border text-muted hover:text-primary hover:bg-surface-hover hover:border-primary hover:shadow-sm'
} rounded-lg p-2 font-mono transition-all duration-200 flex-shrink-0"
@click=${this.toggleMenu}
@keydown=${this.handleMenuButtonKeyDown}
title="Upload Image"
aria-label="Upload image menu"
aria-expanded=${this.showMenu}
data-testid="image-upload-button"
>
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<path d="M14.5 2h-13C.67 2 0 2.67 0 3.5v9c0 .83.67 1.5 1.5 1.5h13c.83 0 1.5-.67 1.5-1.5v-9c0-.83-.67-1.5-1.5-1.5zM5.5 5a1.5 1.5 0 110 3 1.5 1.5 0 010-3zM13 11H3l2.5-3L7 10l2.5-3L13 11z"/>
</svg>
</button>
</vt-tooltip>
${this.showMenu ? this.renderDropdown() : nothing}
</div>
`;
}
private renderDropdown() {
// Use immutable index tracking for menu items
const menuItems = this.getAvailableMenuItems();
let buttonIndex = 0;
return html`
<div
class="absolute right-0 top-full mt-2 bg-surface border border-border rounded-lg shadow-xl py-1 min-w-[240px]"
style="z-index: ${Z_INDEX.WIDTH_SELECTOR_DROPDOWN};"
>
${menuItems.map((item) => {
if (item.isDivider) {
return html`<div class="border-t border-border my-1"></div>`;
}
const currentIndex = buttonIndex++;
return html`
<button
class="w-full text-left px-4 py-3 text-sm font-mono text-primary hover:bg-secondary hover:text-primary flex items-center gap-3 ${
this.focusedIndex === currentIndex ? 'bg-secondary text-primary' : ''
}"
@click=${item.action}
data-action=${item.id}
tabindex="${this.showMenu ? '0' : '-1'}"
aria-label=${item.ariaLabel}
>
${item.icon}
${item.label}
</button>
`;
})}
</div>
`;
}
}

View file

@ -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
</button>
<!-- Upload Image -->
<button
class="w-full text-left px-4 py-3 text-sm font-mono text-primary hover:bg-secondary hover:text-primary flex items-center gap-3 ${this.focusedIndex === menuItemIndex++ ? 'bg-secondary text-primary' : ''}"
@click=${() => this.handleAction(this.onUploadImage)}
data-testid="mobile-upload-image"
tabindex="${this.showMenu ? '0' : '-1'}"
>
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<path d="M14.5 2h-13C.67 2 0 2.67 0 3.5v9c0 .83.67 1.5 1.5 1.5h13c.83 0 1.5-.67 1.5-1.5v-9c0-.83-.67-1.5-1.5-1.5zM5.5 5a1.5 1.5 0 110 3 1.5 1.5 0 010-3zM13 11H3l2.5-3L7 10l2.5-3L13 11z"/>
</svg>
Upload Image
</button>
<!-- Width Settings -->
<button
class="w-full text-left px-4 py-3 text-sm font-mono text-primary hover:bg-secondary hover:text-primary flex items-center gap-3 ${this.focusedIndex === menuItemIndex++ ? 'bg-secondary text-primary' : ''}"

View file

@ -17,6 +17,7 @@ import { isAIAssistantSession, sendAIPrompt } from '../../utils/ai-sessions.js';
import { createLogger } from '../../utils/logger.js';
import './mobile-menu.js';
import '../theme-toggle-icon.js';
import './image-upload-menu.js';
const logger = createLogger('session-header');
@ -247,21 +248,15 @@ export class SessionHeader extends LitElement {
}}
></theme-toggle-icon>
<button
class="bg-bg-tertiary border border-border rounded-lg p-2 font-mono text-muted transition-all duration-200 hover:text-primary hover:bg-surface-hover hover:border-primary hover:shadow-sm flex-shrink-0"
@click=${(e: Event) => {
e.stopPropagation();
this.onOpenFileBrowser?.();
}}
title="Browse Files (⌘O)"
data-testid="file-browser-button"
>
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<path
d="M1.75 1h5.5c.966 0 1.75.784 1.75 1.75v1h4c.966 0 1.75.784 1.75 1.75v7.75A1.75 1.75 0 0113 15H3a1.75 1.75 0 01-1.75-1.75V2.75C1.25 1.784 1.784 1 1.75 1zM2.75 2.5v10.75c0 .138.112.25.25.25h10a.25.25 0 00.25-.25V5.5a.25.25 0 00-.25-.25H8.75v-2.5a.25.25 0 00-.25-.25h-5.5a.25.25 0 00-.25.25z"
/>
</svg>
</button>
<!-- Image Upload Menu -->
<image-upload-menu
.onPasteImage=${() => this.handlePasteImage()}
.onSelectImage=${() => this.handleSelectImage()}
.onOpenCamera=${() => this.handleOpenCamera()}
.onBrowseFiles=${() => this.onOpenFileBrowser?.()}
.isMobile=${this.isMobile}
></image-upload-menu>
<button
class="bg-bg-tertiary border border-border rounded-lg px-3 py-2 font-mono text-xs text-muted transition-all duration-200 hover:text-primary hover:bg-surface-hover hover:border-primary hover:shadow-sm flex-shrink-0 width-selector-button"
@click=${() => this.onMaxWidthToggle?.()}
@ -278,6 +273,7 @@ export class SessionHeader extends LitElement {
.widthLabel=${this.widthLabel}
.widthTooltip=${this.widthTooltip}
.onOpenFileBrowser=${this.onOpenFileBrowser}
.onUploadImage=${() => this.handleMobileUploadImage()}
.onMaxWidthToggle=${this.onMaxWidthToggle}
.onOpenSettings=${this.onOpenSettings}
.onCreateSession=${this.onCreateSession}
@ -340,4 +336,44 @@ export class SessionHeader extends LitElement {
private handleMouseLeave = () => {
this.isHovered = false;
};
private handlePasteImage() {
// Dispatch event to session-view to handle paste
this.dispatchEvent(
new CustomEvent('paste-image', {
bubbles: true,
composed: true,
})
);
}
private handleSelectImage() {
// Always dispatch select-image event to trigger the OS picker directly
this.dispatchEvent(
new CustomEvent('select-image', {
bubbles: true,
composed: true,
})
);
}
private handleOpenCamera() {
// Dispatch event to session-view to open camera
this.dispatchEvent(
new CustomEvent('open-camera', {
bubbles: true,
composed: true,
})
);
}
private handleMobileUploadImage() {
// Directly trigger the OS image picker
this.dispatchEvent(
new CustomEvent('select-image', {
bubbles: true,
composed: true,
})
);
}
}

View file

@ -56,13 +56,12 @@ describe('formatSessionDuration', () => {
expect(formatSessionDuration('')).toBe('0s');
});
it('should ignore end time if it is before start time', () => {
it('should return "0s" when end time is before start time', () => {
const startTime = new Date('2024-01-01T10:00:00Z');
const endTime = new Date('2024-01-01T09:00:00Z'); // 1 hour before start
// Should calculate from start to now instead
// Should return "0s" for invalid duration
const result = formatSessionDuration(startTime.toISOString(), endTime.toISOString());
// Result will be based on current time, so we just check it's not negative
expect(result).toMatch(/^\d+[dhms]/);
expect(result).toBe('0s');
});
});