allow paste of images on mobile

This commit is contained in:
Dylan Knutson
2025-07-30 07:15:18 +00:00
parent 3ad3517138
commit 0ecada567d
2 changed files with 174 additions and 20 deletions

View File

@@ -11,12 +11,15 @@
<div class="bg-white rounded-lg border border-slate-300 shadow-sm overflow-hidden">
<div class="p-4 sm:p-6">
<%= form_with url: visual_results_domain_posts_path, method: :post, multipart: true, class: "flex flex-col gap-4" do |form| %>
<div id="drag-drop-area" class="border-2 border-dashed border-slate-300 rounded-lg p-6 text-center mb-4 transition-colors duration-200">
<div id="drag-drop-area" class="border-2 border-dashed border-slate-300 rounded-lg p-6 text-center mb-4 transition-colors duration-200 cursor-pointer focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 relative" tabindex="0">
<!-- Hidden input for mobile paste functionality -->
<input type="text" id="paste-input" class="absolute opacity-0 pointer-events-none" style="left: -9999px;" autocomplete="off">
<div class="flex flex-col items-center justify-center gap-2">
<svg xmlns="http://www.w3.org/2000/svg" class="h-10 w-10 text-slate-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
<p class="text-slate-600 font-medium">Drag and drop an image here</p>
<p class="text-slate-600 font-medium hidden sm:block">Drag and drop an image here</p>
<p class="text-slate-600 font-medium sm:hidden block">tap here to paste an image from the clipboard</p>
<p class="text-xs text-slate-500">or use one of the options below</p>
</div>
</div>
@@ -50,6 +53,11 @@
document.addEventListener('DOMContentLoaded', function() {
const dragDropArea = document.getElementById('drag-drop-area');
const fileInput = document.getElementById('image-file-input');
const pasteInput = document.getElementById('paste-input');
// Detect if user is on mobile device
const isMobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent) ||
(navigator.maxTouchPoints && navigator.maxTouchPoints > 2);
// Prevent default drag behaviors
['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => {
@@ -70,6 +78,44 @@
// Handle dropped files
dragDropArea.addEventListener('drop', handleDrop, false);
// Handle click/tap on drag-drop area
dragDropArea.addEventListener('click', async function() {
if (isMobile) {
// On mobile, focus the hidden input to enable paste context menu
pasteInput.focus();
// Try modern Clipboard API first if available
if (navigator.clipboard && navigator.clipboard.read) {
try {
await tryClipboardAPIRead();
} catch (err) {
// Fallback to showing instruction for manual paste
showMobilePasteInstruction();
}
} else {
showMobilePasteInstruction();
}
} else {
// On desktop, focus the main area for keyboard paste
dragDropArea.focus();
}
});
// Listen for paste events
pasteInput.addEventListener('paste', handlePaste, false);
dragDropArea.addEventListener('paste', handlePaste, false);
document.addEventListener('paste', handlePaste, false);
// Handle keyboard events for accessibility
dragDropArea.addEventListener('keydown', function(e) {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
if (isMobile) {
pasteInput.focus();
showMobilePasteInstruction();
}
}
});
function preventDefaults(e) {
e.preventDefault();
e.stopPropagation();
@@ -85,34 +131,138 @@
dragDropArea.classList.remove('bg-blue-50');
}
function showMobilePasteInstruction() {
// Create a temporary instruction message for mobile
const instruction = document.createElement('p');
instruction.textContent = 'Long press this area and select "Paste" from the menu, or use your browser\'s paste option';
instruction.className = 'text-sm text-blue-600 mt-2 paste-instruction';
// Remove any previous instruction
const previousInstruction = dragDropArea.querySelector('.paste-instruction');
if (previousInstruction) {
previousInstruction.remove();
}
dragDropArea.appendChild(instruction);
// Remove instruction after 5 seconds (longer for mobile users to read)
setTimeout(() => {
if (instruction.parentNode) {
instruction.remove();
}
}, 5000);
}
async function tryClipboardAPIRead() {
try {
const clipboardItems = await navigator.clipboard.read();
for (const clipboardItem of clipboardItems) {
for (const type of clipboardItem.types) {
if (type.startsWith('image/')) {
const blob = await clipboardItem.getType(type);
const file = new File([blob], 'clipboard-image.png', { type: blob.type });
handleImageFile(file);
return; // Successfully handled
}
}
}
// No image found
showFeedbackMessage('No image found in clipboard. Copy an image first, then try again.', 'text-amber-600');
} catch (err) {
// API failed, throw to trigger fallback
throw err;
}
}
function handlePaste(e) {
e.preventDefault();
const clipboardItems = e.clipboardData.items;
let imageFile = null;
// Look for image data in clipboard
for (let i = 0; i < clipboardItems.length; i++) {
const item = clipboardItems[i];
if (item.type.indexOf('image') !== -1) {
imageFile = item.getAsFile();
break;
}
}
if (imageFile) {
handleImageFile(imageFile);
} else {
// Show message if no image found in clipboard
showFeedbackMessage('No image found in clipboard. Copy an image first, then paste here.', 'text-amber-600');
}
}
function handleDrop(e) {
const dt = e.dataTransfer;
const files = dt.files;
if (files.length) {
// Check if the dropped file is an image
const file = files[0];
if (file.type.match('image.*')) {
// Update the file input with the dropped file
fileInput.files = files;
// Show file name as feedback
const fileName = document.createElement('p');
fileName.textContent = `Selected: ${file.name}`;
fileName.className = 'text-sm text-blue-600 mt-2';
// Remove any previous file name
const previousFileName = dragDropArea.querySelector('.text-blue-600');
if (previousFileName) {
previousFileName.remove();
}
dragDropArea.appendChild(fileName);
handleImageFile(file);
} else {
// Alert if file is not an image
alert('Please drop an image file.');
showFeedbackMessage('Please drop an image file.', 'text-red-600');
}
}
}
function handleImageFile(file) {
// Create a new FileList-like object
const dataTransfer = new DataTransfer();
dataTransfer.items.add(file);
fileInput.files = dataTransfer.files;
// Show success feedback
const fileName = file.name || 'Pasted image';
showFeedbackMessage(`Selected: ${fileName}`, 'text-green-600');
// Add visual feedback to the drag-drop area
highlight();
setTimeout(unhighlight, 1000);
}
function showFeedbackMessage(message, className) {
// Create feedback message
const feedback = document.createElement('p');
feedback.textContent = message;
feedback.className = `text-sm ${className} mt-2 feedback-message`;
// Remove any previous feedback
const previousFeedback = dragDropArea.querySelector('.feedback-message');
if (previousFeedback) {
previousFeedback.remove();
}
dragDropArea.appendChild(feedback);
// Remove feedback after 5 seconds
setTimeout(() => {
if (feedback.parentNode) {
feedback.remove();
}
}, 5000);
}
// Add additional mobile support
if (isMobile) {
// Enable long press context menu on the hidden input
pasteInput.addEventListener('contextmenu', function(e) {
// Allow context menu to show (which includes paste option)
e.stopPropagation();
});
// Handle touch start to prepare for paste
dragDropArea.addEventListener('touchstart', function(e) {
// Focus the hidden input to enable paste functionality
setTimeout(() => {
pasteInput.focus();
}, 100);
}, { passive: true });
}
});
</script>

View File

@@ -113,6 +113,10 @@
<i class="fas fa-chart-bar mr-3 w-4 text-slate-400"></i>
<span>Prometheus</span>
<% end %>
<%= link_to log_entries_path, class: "flex w-full items-center px-4 py-2 text-sm text-slate-700 transition-colors hover:bg-slate-50 hover:text-slate-900" do %>
<i class="fas fa-list mr-3 w-4 text-slate-400"></i>
<span>Log Entries</span>
<% end %>
<div class="my-2 border-t border-slate-200"></div>
</div>
<% end %>