Add copy to clipboard and save to pc toggle.

* escaped node modules from vcs

* feat: added copy to clipboard feature

* feat: added tests

* feat: added feature spec

* feat: added toggle for save to pc

* feat: added tests

* refactor: change toggle from '?' button to the 'switch' component

* Fix failing tests

* Update gitignore and remove unnecessary files

* Remove tooltips because they are unnecessary

---------

Co-authored-by: Joey Yakimowich-Payne <jyapayne@gmail.com>
This commit is contained in:
Karthikeyan N 2025-08-14 00:59:57 +05:30 • committed by Joey
commit 9dd4d706fa
12 changed files with 7780 additions and 22 deletions

View file

@ -6,15 +6,19 @@ class ScreenshotSelector {
this.overlay = null;
this.style = null;
this.background = 'black';
this.copyToClipboard = true; // Default to true
this.saveToPc = true; // Default to true for backward compatibility
}
async init(background = 'black') {
async init(background = 'black', copyToClipboard = true, saveToPc = true) {
if (this.isActive) {
console.log('Screenshot selector already active');
return;
}
this.background = background;
this.copyToClipboard = copyToClipboard;
this.saveToPc = saveToPc;
this.isActive = true;
this.createUI();
@ -363,12 +367,36 @@ class ScreenshotSelector {
async captureElement(element) {
try {
// Determine loading message based on enabled operations
let loadingMessage = 'Capturing screenshot...';
if (this.saveToPc && this.copyToClipboard) {
// Both operations enabled
const availability = this.checkClipboardAPIAvailability();
if (availability.available) {
loadingMessage = 'Capturing screenshot and preparing for clipboard...';
} else {
loadingMessage = 'Capturing screenshot for download... (Clipboard not available)';
}
} else if (this.saveToPc && !this.copyToClipboard) {
// Only save to PC enabled
loadingMessage = 'Capturing screenshot for download...';
} else if (!this.saveToPc && this.copyToClipboard) {
// Only clipboard enabled
const availability = this.checkClipboardAPIAvailability();
if (availability.available) {
loadingMessage = 'Capturing screenshot for clipboard...';
} else {
loadingMessage = 'Capturing screenshot... (Clipboard not available)';
}
}
// Show loading state
this.overlay.innerHTML = `
<div style="position: fixed; top: 20px; left: 20px; background: rgba(0,0,0,0.9); color: white; padding: 12px 16px; border-radius: 8px; z-index: 2147483647; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; font-size: 14px; box-shadow: 0 4px 20px rgba(0,0,0,0.3);">
<div style="display: flex; align-items: center; gap: 10px;">
<div style="width: 16px; height: 16px; border: 2px solid #fff; border-top: 2px solid transparent; border-radius: 50%; animation: spin 1s linear infinite;"></div>
<span>Capturing screenshot...</span>
<span>${loadingMessage}</span>
</div>
</div>
<style>
@ -467,18 +495,82 @@ class ScreenshotSelector {
// Restore original styles
Object.assign(element.style, originalStyles);
// Download image
const link = document.createElement('a');
link.href = canvas.toDataURL('image/png');
link.download = `screenshot-${new Date().toISOString().slice(0, 19).replace(/[:.]/g, '-')}.png`;
link.click();
// Conditionally download the image based on saveToPc preference
if (this.saveToPc) {
const link = document.createElement('a');
link.href = canvas.toDataURL('image/png');
link.download = `screenshot-${new Date().toISOString().slice(0, 19).replace(/[:.]/g, '-')}.png`;
link.click();
}
// Handle clipboard operations if enabled (after successful canvas generation and download)
let clipboardResult = null;
if (this.copyToClipboard) {
const availability = this.checkClipboardAPIAvailability();
if (availability.available) {
try {
// Attempt clipboard operation with comprehensive error handling
clipboardResult = await this.copyCanvasToClipboard(canvas);
} catch (error) {
// Ensure clipboard errors don't break the workflow - this is a safety net
// The copyCanvasToClipboard method should handle all errors internally
console.error('Unexpected clipboard error caught in captureElement:', error);
clipboardResult = {
success: false,
error: 'Unexpected clipboard error occurred',
errorType: 'unexpected'
};
}
} else {
clipboardResult = {
success: false,
error: availability.reason,
errorType: 'api_unavailable'
};
}
}
// Generate user feedback message based on operation combinations
let successMessage = 'Screenshot captured!';
let messageColor = 'rgba(39, 174, 96, 0.95)'; // Green for success
let messageIcon = '✅';
// Determine success message based on enabled operations
if (this.saveToPc && this.copyToClipboard) {
// Both operations enabled
if (clipboardResult && clipboardResult.success) {
successMessage = 'Screenshot saved and copied to clipboard!';
messageIcon = '✅';
} else {
// Save succeeded but clipboard failed
const errorMessage = this.getClipboardErrorMessage(clipboardResult);
successMessage = `Screenshot saved successfully! However, ${errorMessage.toLowerCase()}`;
messageColor = 'rgba(243, 156, 18, 0.95)'; // Orange for partial success
messageIcon = '⚠️';
}
} else if (this.saveToPc && !this.copyToClipboard) {
// Only save to PC enabled
successMessage = 'Screenshot saved to downloads folder!';
messageIcon = '💾';
} else if (!this.saveToPc && this.copyToClipboard) {
// Only clipboard enabled
if (clipboardResult && clipboardResult.success) {
successMessage = 'Screenshot copied to clipboard!';
messageIcon = '📋';
} else {
// Clipboard failed - this is a complete failure since no other output method is enabled
const errorMessage = this.getClipboardErrorMessage(clipboardResult);
successMessage = `Screenshot capture failed: ${errorMessage}`;
messageColor = 'rgba(231, 76, 60, 0.95)'; // Red for failure
messageIcon = '❌';
}
}
// Success message
this.overlay.innerHTML = `
<div style="position: fixed; top: 20px; left: 20px; background: rgba(39, 174, 96, 0.95); color: white; padding: 12px 16px; border-radius: 8px; z-index: 2147483647; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; font-size: 14px; box-shadow: 0 4px 20px rgba(0,0,0,0.3);">
<div style="position: fixed; top: 20px; left: 20px; background: ${messageColor}; color: white; padding: 12px 16px; border-radius: 8px; z-index: 2147483647; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; font-size: 14px; box-shadow: 0 4px 20px rgba(0,0,0,0.3);">
<div style="display: flex; align-items: center; gap: 10px;">
<span>✅</span>
<span>Screenshot saved successfully!</span>
<span>${messageIcon}</span>
<span>${successMessage}</span>
</div>
</div>
`;
@ -490,18 +582,329 @@ class ScreenshotSelector {
} catch (error) {
console.error('Screenshot failed:', error);
// Provide specific error messages for screenshot failures based on enabled operations
let errorMessage = 'Screenshot capture failed. Please try again.';
let operationContext = '';
// Add context about what operations were attempted
if (this.saveToPc && this.copyToClipboard) {
operationContext = ' Neither file download nor clipboard copy could be completed.';
} else if (this.saveToPc && !this.copyToClipboard) {
operationContext = ' File download could not be completed.';
} else if (!this.saveToPc && this.copyToClipboard) {
operationContext = ' Clipboard copy could not be completed.';
}
if (error.message) {
if (error.message.includes('html2canvas')) {
errorMessage = `Screenshot rendering failed. Try selecting a different element or refresh the page.${operationContext}`;
} else if (error.message.includes('timeout')) {
errorMessage = `Screenshot capture timed out. Try selecting a smaller area or simpler element.${operationContext}`;
} else if (error.message.includes('network') || error.message.includes('CORS')) {
errorMessage = `Screenshot failed: Some content is blocked by security restrictions.${operationContext}`;
} else if (error.message.includes('memory') || error.message.includes('quota')) {
errorMessage = `Screenshot failed: Not enough memory. Try capturing a smaller area.${operationContext}`;
} else if (error.message.includes('canvas')) {
errorMessage = `Screenshot failed: Unable to create image. Try a different element.${operationContext}`;
} else {
errorMessage = `Screenshot capture failed: ${error.message}${operationContext}`;
}
} else {
errorMessage = `Screenshot capture failed. Please try again.${operationContext}`;
}
this.overlay.innerHTML = `
<div style="position: fixed; top: 20px; left: 20px; background: rgba(231, 76, 60, 0.95); color: white; padding: 12px 16px; border-radius: 8px; z-index: 2147483647; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; font-size: 14px; box-shadow: 0 4px 20px rgba(0,0,0,0.3);">
<div style="display: flex; align-items: center; gap: 10px;">
<span>❌</span>
<span>Screenshot failed. Please try again.</span>
<span>${errorMessage}</span>
</div>
</div>
`;
// Notify popup about the failure
chrome.runtime.sendMessage({ action: 'selectorStopped', reason: 'screenshot-failed' });
setTimeout(() => this.cleanup(), 3000);
}
}
/**
* Check if Clipboard API is available and supported
* @returns {{available: boolean, reason?: string}} Clipboard API availability status
*/
checkClipboardAPIAvailability() {
// Check for basic Clipboard API support
if (!navigator.clipboard) {
return {
available: false,
reason: 'Clipboard API not available in this browser'
};
}
// Check for ClipboardItem constructor (required for image data)
if (!window.ClipboardItem) {
return {
available: false,
reason: 'ClipboardItem not supported in this browser'
};
}
// Check if we're in a secure context (HTTPS required for clipboard)
if (!window.isSecureContext) {
return {
available: false,
reason: 'Clipboard API requires a secure context (HTTPS)'
};
}
// Check for write permission (this is the basic check, actual permission is checked during write)
if (!navigator.clipboard.write) {
return {
available: false,
reason: 'Clipboard write functionality not available'
};
}
return {
available: true
};
}
/**
* Get user-friendly feedback message about clipboard feature availability
* @returns {{supported: boolean, message: string}} User feedback about clipboard support
*/
getClipboardSupportFeedback() {
const availability = this.checkClipboardAPIAvailability();
if (availability.available) {
return {
supported: true,
message: 'Clipboard copying is available and ready to use.'
};
}
// Provide user-friendly messages for different scenarios
let userMessage = '';
if (availability.reason.includes('not available') || availability.reason.includes('not supported')) {
userMessage = 'Your browser doesn\'t support clipboard copying. Screenshots will still be downloaded.';
} else if (availability.reason.includes('secure context')) {
userMessage = 'Clipboard copying requires HTTPS. Screenshots will still be downloaded.';
} else if (availability.reason.includes('write functionality')) {
userMessage = 'Clipboard writing is not available. Screenshots will still be downloaded.';
} else {
userMessage = 'Clipboard copying is not available. Screenshots will still be downloaded.';
}
return {
supported: false,
message: userMessage
};
}
/**
* Generate user-friendly error message for clipboard operation failures
* @param {Object} clipboardResult - Result object from clipboard operation
* @returns {string} User-friendly error message
*/
getClipboardErrorMessage(clipboardResult) {
if (!clipboardResult || !clipboardResult.error) {
return 'clipboard copy failed due to unknown error';
}
const errorType = clipboardResult.errorType;
const errorMessage = clipboardResult.error;
// Provide contextual error messages based on error type
// Note: These messages are designed to work in the context of "Screenshot saved successfully! However, [message]"
switch (errorType) {
case 'permission_denied':
return 'clipboard copy failed: permission denied. Please allow clipboard access in your browser settings';
case 'not_supported':
case 'api_unavailable':
return 'clipboard copy is not available in this browser';
case 'security_error':
return 'clipboard copy was blocked by browser security. Try using HTTPS or check site permissions';
case 'size_limit':
case 'quota_exceeded':
return 'clipboard copy failed: image too large. Try capturing a smaller area';
case 'timeout':
return 'clipboard copy timed out. The image may be too large or complex';
case 'network_error':
return 'clipboard copy failed due to network error. Please try again';
case 'canvas_conversion':
return 'clipboard copy failed: unable to prepare image. Try capturing a different element';
case 'clipboard_item_creation':
return 'clipboard copy is not supported: your browser doesn\'t support image clipboard operations';
case 'invalid_state':
return 'clipboard copy failed: browser clipboard is busy. Please try again';
case 'data_error':
return 'clipboard copy failed: invalid image data. Please try capturing again';
case 'unexpected':
return 'clipboard copy failed due to unexpected error';
default:
// For unknown error types, try to extract meaningful info from the error message
if (errorMessage.includes('permission') || errorMessage.includes('denied')) {
return 'clipboard copy failed: access denied. Check browser permissions';
} else if (errorMessage.includes('not supported') || errorMessage.includes('unavailable')) {
return 'clipboard copy is not supported in this browser';
} else if (errorMessage.includes('timeout') || errorMessage.includes('timed out')) {
return 'clipboard copy timed out. Try capturing a smaller area';
} else if (errorMessage.includes('large') || errorMessage.includes('size')) {
return 'clipboard copy failed: image too large for clipboard';
} else {
return `clipboard copy failed: ${errorMessage.toLowerCase()}`;
}
}
}
/**
* Copy canvas content to clipboard using the Clipboard API
* @param {HTMLCanvasElement} canvas - The canvas element to copy
* @returns {Promise<{success: boolean, error?: string, errorType?: string}>} Result of clipboard operation
*/
async copyCanvasToClipboard(canvas) {
try {
// Check clipboard API availability first
const availability = this.checkClipboardAPIAvailability();
if (!availability.available) {
return {
success: false,
error: availability.reason,
errorType: 'api_unavailable'
};
}
// Convert canvas to blob in PNG format with timeout
const blob = await Promise.race([
new Promise((resolve, reject) => {
canvas.toBlob((blob) => {
if (blob) {
resolve(blob);
} else {
reject(new Error('Canvas to blob conversion returned null'));
}
}, 'image/png');
}),
new Promise((_, reject) =>
setTimeout(() => reject(new Error('Canvas to blob conversion timed out')), 10000)
)
]);
// Validate blob size (prevent extremely large clipboard operations)
const maxBlobSize = 50 * 1024 * 1024; // 50MB limit
if (blob.size > maxBlobSize) {
return {
success: false,
error: `Image too large for clipboard (${Math.round(blob.size / 1024 / 1024)}MB). Try capturing a smaller area.`,
errorType: 'size_limit'
};
}
// Create ClipboardItem with PNG image data
let clipboardItem;
try {
clipboardItem = new ClipboardItem({
'image/png': blob
});
} catch (clipboardItemError) {
return {
success: false,
error: 'Failed to create clipboard item. Your browser may not support image clipboard operations.',
errorType: 'clipboard_item_creation'
};
}
// Write to clipboard with timeout
await Promise.race([
navigator.clipboard.write([clipboardItem]),
new Promise((_, reject) =>
setTimeout(() => reject(new Error('Clipboard write operation timed out')), 15000)
)
]);
return { success: true };
} catch (error) {
console.error('Clipboard operation failed:', error);
// Comprehensive error handling with specific error types and user-friendly messages
let errorMessage = error.message || 'Unknown clipboard error';
let errorType = 'unknown';
// Permission-related errors
if (error.name === 'NotAllowedError') {
errorMessage = 'Clipboard access denied. Please allow clipboard permissions in your browser settings.';
errorType = 'permission_denied';
}
// API support errors
else if (error.name === 'NotSupportedError') {
errorMessage = 'Clipboard API not supported in this browser context.';
errorType = 'not_supported';
}
// Security context errors
else if (error.name === 'SecurityError') {
errorMessage = 'Clipboard access blocked by security policy. Try using HTTPS.';
errorType = 'security_error';
}
// Network or resource errors
else if (error.name === 'NetworkError') {
errorMessage = 'Network error during clipboard operation. Please try again.';
errorType = 'network_error';
}
// Timeout errors
else if (error.message.includes('timed out')) {
errorMessage = 'Clipboard operation timed out. The image may be too large.';
errorType = 'timeout';
}
// Canvas conversion errors
else if (error.message.includes('canvas') || error.message.includes('blob')) {
errorMessage = 'Failed to prepare image for clipboard. Try capturing a different area.';
errorType = 'canvas_conversion';
}
// Quota or storage errors
else if (error.name === 'QuotaExceededError' || error.message.includes('quota')) {
errorMessage = 'Clipboard storage quota exceeded. Try capturing a smaller image.';
errorType = 'quota_exceeded';
}
// DOM or state errors
else if (error.name === 'InvalidStateError') {
errorMessage = 'Clipboard is in an invalid state. Please try again.';
errorType = 'invalid_state';
}
// Data errors
else if (error.name === 'DataError') {
errorMessage = 'Invalid image data for clipboard. Please try again.';
errorType = 'data_error';
}
// Generic fallback for unknown errors
else {
errorMessage = `Clipboard operation failed: ${error.message}`;
errorType = 'unknown';
}
return {
success: false,
error: errorMessage,
errorType: errorType
};
}
}
cleanup() {
document.removeEventListener('mouseover', this.handleMouseOver);
document.removeEventListener('click', this.handleClick, true);
@ -540,7 +943,11 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
try {
switch (message.action) {
case 'startSelector':
screenshotSelector.init(message.background);
// Extract clipboard preference from message, default to true for backward compatibility
const copyToClipboard = message.copyToClipboard !== undefined ? message.copyToClipboard : true;
// Extract save to PC preference from message, default to true for backward compatibility
const saveToPc = message.saveToPc !== undefined ? message.saveToPc : true;
screenshotSelector.init(message.background, copyToClipboard, saveToPc);
sendResponse({ success: true });
break;
@ -553,6 +960,15 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
sendResponse({ isActive: screenshotSelector.isActive });
break;
case 'checkClipboardSupport':
const availability = screenshotSelector.checkClipboardAPIAvailability();
const feedback = screenshotSelector.getClipboardSupportFeedback();
sendResponse({
availability: availability,
feedback: feedback
});
break;
default:
sendResponse({ error: 'Unknown action: ' + message.action });
}
@ -569,7 +985,7 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (message.action === 'activateFromShortcut') {
if (!screenshotSelector.isActive) {
screenshotSelector.init('black'); // Default background for shortcut
screenshotSelector.init('black', true); // Default background and clipboard enabled for shortcut
}
}
});