mirror of
https://github.com/samsonjs/vibetunnel.git
synced 2026-04-27 15:17:38 +00:00
Add image upload menu with paste, select, camera, and browse options (#432)
This commit is contained in:
parent
3aa63bd637
commit
690ac8fe48
8 changed files with 1051 additions and 22 deletions
|
|
@ -117,6 +117,36 @@ export class FilePicker extends LitElement {
|
||||||
this.handleFileClick();
|
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> {
|
private async uploadFileToServer(file: File): Promise<void> {
|
||||||
this.uploading = true;
|
this.uploading = true;
|
||||||
this.uploadProgress = 0;
|
this.uploadProgress = 0;
|
||||||
|
|
@ -199,7 +229,8 @@ export class FilePicker extends LitElement {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.fileInput) {
|
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.removeAttribute('capture');
|
||||||
this.fileInput.click();
|
this.fileInput.click();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -131,10 +131,10 @@ export class KeyboardCaptureIndicator extends LitElement {
|
||||||
|
|
||||||
// Use the same button styling as other header buttons
|
// Use the same button styling as other header buttons
|
||||||
const buttonClasses = `
|
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
|
transition-all duration-200 hover:text-primary hover:bg-surface-hover hover:border-primary
|
||||||
hover:shadow-sm flex-shrink-0
|
hover:shadow-sm flex-shrink-0
|
||||||
${this.active ? 'text-primary border-primary' : ''}
|
${this.active ? 'text-primary' : 'text-muted'}
|
||||||
${this.animating ? 'animating' : ''}
|
${this.animating ? 'animating' : ''}
|
||||||
`.trim();
|
`.trim();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1005,6 +1005,67 @@ export class SessionView extends LitElement {
|
||||||
this.showImagePicker = false;
|
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) {
|
private async handleFileSelected(event: CustomEvent) {
|
||||||
const { path } = event.detail;
|
const { path } = event.detail;
|
||||||
if (!path || !this.session) return;
|
if (!path || !this.session) return;
|
||||||
|
|
@ -1377,6 +1438,10 @@ export class SessionView extends LitElement {
|
||||||
this.customWidth = '';
|
this.customWidth = '';
|
||||||
}}
|
}}
|
||||||
@session-rename=${(e: CustomEvent) => this.handleRename(e)}
|
@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) => {
|
@capture-toggled=${(e: CustomEvent) => {
|
||||||
this.dispatchEvent(
|
this.dispatchEvent(
|
||||||
new CustomEvent('capture-toggled', {
|
new CustomEvent('capture-toggled', {
|
||||||
|
|
@ -1638,6 +1703,7 @@ export class SessionView extends LitElement {
|
||||||
@file-error=${this.handleFileError}
|
@file-error=${this.handleFileError}
|
||||||
@file-cancel=${this.handleCloseFilePicker}
|
@file-cancel=${this.handleCloseFilePicker}
|
||||||
></file-picker>
|
></file-picker>
|
||||||
|
|
||||||
|
|
||||||
<!-- Width Selector Modal (moved here for proper positioning) -->
|
<!-- Width Selector Modal (moved here for proper positioning) -->
|
||||||
<terminal-settings-modal
|
<terminal-settings-modal
|
||||||
|
|
|
||||||
579
web/src/client/components/session-view/image-upload-menu.test.ts
Normal file
579
web/src/client/components/session-view/image-upload-menu.test.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
304
web/src/client/components/session-view/image-upload-menu.ts
Normal file
304
web/src/client/components/session-view/image-upload-menu.ts
Normal 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>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -22,6 +22,7 @@ export class MobileMenu extends LitElement {
|
||||||
@property({ type: String }) widthTooltip = '';
|
@property({ type: String }) widthTooltip = '';
|
||||||
@property({ type: Function }) onCreateSession?: () => void;
|
@property({ type: Function }) onCreateSession?: () => void;
|
||||||
@property({ type: Function }) onOpenFileBrowser?: () => void;
|
@property({ type: Function }) onOpenFileBrowser?: () => void;
|
||||||
|
@property({ type: Function }) onUploadImage?: () => void;
|
||||||
@property({ type: Function }) onMaxWidthToggle?: () => void;
|
@property({ type: Function }) onMaxWidthToggle?: () => void;
|
||||||
@property({ type: Function }) onOpenSettings?: () => void;
|
@property({ type: Function }) onOpenSettings?: () => void;
|
||||||
@property({ type: String }) currentTheme: Theme = 'system';
|
@property({ type: String }) currentTheme: Theme = 'system';
|
||||||
|
|
@ -260,6 +261,19 @@ export class MobileMenu extends LitElement {
|
||||||
Browse Files
|
Browse Files
|
||||||
</button>
|
</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 -->
|
<!-- Width Settings -->
|
||||||
<button
|
<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' : ''}"
|
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' : ''}"
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ import { isAIAssistantSession, sendAIPrompt } from '../../utils/ai-sessions.js';
|
||||||
import { createLogger } from '../../utils/logger.js';
|
import { createLogger } from '../../utils/logger.js';
|
||||||
import './mobile-menu.js';
|
import './mobile-menu.js';
|
||||||
import '../theme-toggle-icon.js';
|
import '../theme-toggle-icon.js';
|
||||||
|
import './image-upload-menu.js';
|
||||||
|
|
||||||
const logger = createLogger('session-header');
|
const logger = createLogger('session-header');
|
||||||
|
|
||||||
|
|
@ -247,21 +248,15 @@ export class SessionHeader extends LitElement {
|
||||||
}}
|
}}
|
||||||
></theme-toggle-icon>
|
></theme-toggle-icon>
|
||||||
|
|
||||||
<button
|
<!-- Image Upload Menu -->
|
||||||
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"
|
<image-upload-menu
|
||||||
@click=${(e: Event) => {
|
.onPasteImage=${() => this.handlePasteImage()}
|
||||||
e.stopPropagation();
|
.onSelectImage=${() => this.handleSelectImage()}
|
||||||
this.onOpenFileBrowser?.();
|
.onOpenCamera=${() => this.handleOpenCamera()}
|
||||||
}}
|
.onBrowseFiles=${() => this.onOpenFileBrowser?.()}
|
||||||
title="Browse Files (⌘O)"
|
.isMobile=${this.isMobile}
|
||||||
data-testid="file-browser-button"
|
></image-upload-menu>
|
||||||
>
|
|
||||||
<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>
|
|
||||||
<button
|
<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"
|
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?.()}
|
@click=${() => this.onMaxWidthToggle?.()}
|
||||||
|
|
@ -278,6 +273,7 @@ export class SessionHeader extends LitElement {
|
||||||
.widthLabel=${this.widthLabel}
|
.widthLabel=${this.widthLabel}
|
||||||
.widthTooltip=${this.widthTooltip}
|
.widthTooltip=${this.widthTooltip}
|
||||||
.onOpenFileBrowser=${this.onOpenFileBrowser}
|
.onOpenFileBrowser=${this.onOpenFileBrowser}
|
||||||
|
.onUploadImage=${() => this.handleMobileUploadImage()}
|
||||||
.onMaxWidthToggle=${this.onMaxWidthToggle}
|
.onMaxWidthToggle=${this.onMaxWidthToggle}
|
||||||
.onOpenSettings=${this.onOpenSettings}
|
.onOpenSettings=${this.onOpenSettings}
|
||||||
.onCreateSession=${this.onCreateSession}
|
.onCreateSession=${this.onCreateSession}
|
||||||
|
|
@ -340,4 +336,44 @@ export class SessionHeader extends LitElement {
|
||||||
private handleMouseLeave = () => {
|
private handleMouseLeave = () => {
|
||||||
this.isHovered = false;
|
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,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -56,13 +56,12 @@ describe('formatSessionDuration', () => {
|
||||||
expect(formatSessionDuration('')).toBe('0s');
|
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 startTime = new Date('2024-01-01T10:00:00Z');
|
||||||
const endTime = new Date('2024-01-01T09:00:00Z'); // 1 hour before start
|
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());
|
const result = formatSessionDuration(startTime.toISOString(), endTime.toISOString());
|
||||||
// Result will be based on current time, so we just check it's not negative
|
expect(result).toBe('0s');
|
||||||
expect(result).toMatch(/^\d+[dhms]/);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue