mirror of
https://github.com/samsonjs/vibetunnel.git
synced 2026-03-25 09:25:50 +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();
|
||||
}
|
||||
|
||||
/**
|
||||
* 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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
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: 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' : ''}"
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue