Restore three-button capture interface: Share Entire Screen, Snip, Cancel
This commit is contained in:
+348
-14
@@ -17,6 +17,235 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
<!-- Import standalone tools -->
|
||||||
|
<script>
|
||||||
|
// Initialize capture tools globally
|
||||||
|
console.log('Initializing capture tools...');
|
||||||
|
|
||||||
|
window.captureScreenshot = async function() {
|
||||||
|
console.log('captureScreenshot called');
|
||||||
|
try {
|
||||||
|
const mediaStream = await navigator.mediaDevices.getDisplayMedia({
|
||||||
|
video: { mediaSource: 'screen' }
|
||||||
|
});
|
||||||
|
const video = document.createElement('video');
|
||||||
|
video.srcObject = mediaStream;
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
video.onloadedmetadata = () => {
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.width = video.videoWidth;
|
||||||
|
canvas.height = video.videoHeight;
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
ctx.drawImage(video, 0, 0);
|
||||||
|
mediaStream.getTracks().forEach(track => track.stop());
|
||||||
|
resolve(canvas);
|
||||||
|
};
|
||||||
|
video.play();
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Screenshot capture failed:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.captureSnip = async function() {
|
||||||
|
console.log('captureSnip called');
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
navigator.mediaDevices.getDisplayMedia({
|
||||||
|
video: { mediaSource: 'screen' }
|
||||||
|
}).then(mediaStream => {
|
||||||
|
const video = document.createElement('video');
|
||||||
|
video.srcObject = mediaStream;
|
||||||
|
|
||||||
|
video.onloadedmetadata = () => {
|
||||||
|
const fullCanvas = document.createElement('canvas');
|
||||||
|
fullCanvas.width = video.videoWidth;
|
||||||
|
fullCanvas.height = video.videoHeight;
|
||||||
|
const fullCtx = fullCanvas.getContext('2d');
|
||||||
|
fullCtx.drawImage(video, 0, 0);
|
||||||
|
mediaStream.getTracks().forEach(track => track.stop());
|
||||||
|
|
||||||
|
// Create fullscreen preview container
|
||||||
|
const container = document.createElement('div');
|
||||||
|
container.style.cssText = `position:fixed;top:0;left:0;width:100%;height:100%;background:black;z-index:99999;overflow:hidden;`;
|
||||||
|
|
||||||
|
// Create image preview of the captured screen
|
||||||
|
const previewImg = document.createElement('img');
|
||||||
|
previewImg.src = fullCanvas.toDataURL();
|
||||||
|
previewImg.style.cssText = `width:100%;height:100%;object-fit:contain;`;
|
||||||
|
|
||||||
|
// Create overlay canvas for drawing selection
|
||||||
|
const overlayCanvas = document.createElement('canvas');
|
||||||
|
overlayCanvas.style.cssText = `position:absolute;top:0;left:0;cursor:crosshair;display:none;`;
|
||||||
|
|
||||||
|
// Create control panel
|
||||||
|
const controlPanel = document.createElement('div');
|
||||||
|
controlPanel.style.cssText = `position:absolute;bottom:30px;left:50%;transform:translateX(-50%);display:flex;gap:15px;z-index:100001;`;
|
||||||
|
|
||||||
|
const fullScreenBtn = document.createElement('button');
|
||||||
|
fullScreenBtn.textContent = 'Share Entire Screen';
|
||||||
|
fullScreenBtn.style.cssText = `padding:12px 24px;background:#10B981;color:white;border:none;border-radius:6px;font-size:16px;font-weight:bold;cursor:pointer;`;
|
||||||
|
|
||||||
|
const snippetBtn = document.createElement('button');
|
||||||
|
snippetBtn.textContent = 'Snip';
|
||||||
|
snippetBtn.style.cssText = `padding:12px 24px;background:#0EA5E9;color:white;border:none;border-radius:6px;font-size:16px;font-weight:bold;cursor:pointer;`;
|
||||||
|
|
||||||
|
const cancelBtn = document.createElement('button');
|
||||||
|
cancelBtn.textContent = '✕ Cancel';
|
||||||
|
cancelBtn.style.cssText = `padding:12px 24px;background:#EF4444;color:white;border:none;border-radius:6px;font-size:16px;font-weight:bold;cursor:pointer;`;
|
||||||
|
|
||||||
|
controlPanel.appendChild(fullScreenBtn);
|
||||||
|
controlPanel.appendChild(snippetBtn);
|
||||||
|
controlPanel.appendChild(cancelBtn);
|
||||||
|
|
||||||
|
container.appendChild(previewImg);
|
||||||
|
container.appendChild(overlayCanvas);
|
||||||
|
container.appendChild(controlPanel);
|
||||||
|
document.body.appendChild(container);
|
||||||
|
|
||||||
|
let snippingMode = false;
|
||||||
|
|
||||||
|
// Wait for image to load
|
||||||
|
previewImg.onload = () => {
|
||||||
|
const rect = previewImg.getBoundingClientRect();
|
||||||
|
overlayCanvas.width = rect.width;
|
||||||
|
overlayCanvas.height = rect.height;
|
||||||
|
|
||||||
|
const displayWidth = fullCanvas.width;
|
||||||
|
const displayHeight = fullCanvas.height;
|
||||||
|
const scaleX = displayWidth / rect.width;
|
||||||
|
const scaleY = displayHeight / rect.height;
|
||||||
|
|
||||||
|
let isDrawing = false, startX = 0, startY = 0, endX = 0, endY = 0;
|
||||||
|
const ctx = overlayCanvas.getContext('2d');
|
||||||
|
|
||||||
|
function drawSelection() {
|
||||||
|
ctx.clearRect(0, 0, overlayCanvas.width, overlayCanvas.height);
|
||||||
|
if (!isDrawing && startX === endX && startY === endY) return;
|
||||||
|
|
||||||
|
const x = Math.min(startX, endX);
|
||||||
|
const y = Math.min(startY, endY);
|
||||||
|
const w = Math.abs(endX - startX);
|
||||||
|
const h = Math.abs(endY - startY);
|
||||||
|
|
||||||
|
ctx.strokeStyle = '#0EA5E9';
|
||||||
|
ctx.lineWidth = 3;
|
||||||
|
ctx.strokeRect(x, y, w, h);
|
||||||
|
ctx.fillStyle = 'rgba(14, 165, 233, 0.1)';
|
||||||
|
ctx.fillRect(x, y, w, h);
|
||||||
|
|
||||||
|
const sz = 12;
|
||||||
|
ctx.fillStyle = '#0EA5E9';
|
||||||
|
ctx.fillRect(x - sz/2, y - sz/2, sz, sz);
|
||||||
|
ctx.fillRect(x + w - sz/2, y - sz/2, sz, sz);
|
||||||
|
ctx.fillRect(x - sz/2, y + h - sz/2, sz, sz);
|
||||||
|
ctx.fillRect(x + w - sz/2, y + h - sz/2, sz, sz);
|
||||||
|
}
|
||||||
|
|
||||||
|
overlayCanvas.addEventListener('mousedown', (e) => {
|
||||||
|
if (!snippingMode) return;
|
||||||
|
const rect = overlayCanvas.getBoundingClientRect();
|
||||||
|
isDrawing = true;
|
||||||
|
startX = e.clientX - rect.left;
|
||||||
|
startY = e.clientY - rect.top;
|
||||||
|
endX = startX;
|
||||||
|
endY = startY;
|
||||||
|
});
|
||||||
|
|
||||||
|
overlayCanvas.addEventListener('mousemove', (e) => {
|
||||||
|
if (!snippingMode || !isDrawing) return;
|
||||||
|
const rect = overlayCanvas.getBoundingClientRect();
|
||||||
|
endX = e.clientX - rect.left;
|
||||||
|
endY = e.clientY - rect.top;
|
||||||
|
drawSelection();
|
||||||
|
});
|
||||||
|
|
||||||
|
overlayCanvas.addEventListener('mouseup', () => {
|
||||||
|
if (!snippingMode || !isDrawing) return;
|
||||||
|
isDrawing = false;
|
||||||
|
const x = Math.min(startX, endX);
|
||||||
|
const y = Math.min(startY, endY);
|
||||||
|
const w = Math.abs(endX - startX);
|
||||||
|
const h = Math.abs(endY - startY);
|
||||||
|
|
||||||
|
if (w < 10 || h < 10) {
|
||||||
|
console.warn('Selection too small');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const croppedCanvas = document.createElement('canvas');
|
||||||
|
croppedCanvas.width = w * scaleX;
|
||||||
|
croppedCanvas.height = h * scaleY;
|
||||||
|
const croppedCtx = croppedCanvas.getContext('2d');
|
||||||
|
croppedCtx.drawImage(fullCanvas, x * scaleX, y * scaleY, w * scaleX, h * scaleY, 0, 0, w * scaleX, h * scaleY);
|
||||||
|
|
||||||
|
cleanup();
|
||||||
|
resolve(croppedCanvas);
|
||||||
|
});
|
||||||
|
|
||||||
|
fullScreenBtn.addEventListener('click', () => {
|
||||||
|
cleanup();
|
||||||
|
resolve(fullCanvas);
|
||||||
|
});
|
||||||
|
|
||||||
|
snippetBtn.addEventListener('click', () => {
|
||||||
|
snippingMode = true;
|
||||||
|
overlayCanvas.style.display = 'block';
|
||||||
|
controlPanel.style.display = 'none';
|
||||||
|
console.log('Snipping mode active: Draw rectangle to select. ESC to cancel.');
|
||||||
|
});
|
||||||
|
|
||||||
|
cancelBtn.addEventListener('click', () => {
|
||||||
|
cleanup();
|
||||||
|
resolve(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleKeydown = (e) => {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
if (snippingMode) {
|
||||||
|
snippingMode = false;
|
||||||
|
overlayCanvas.style.display = 'none';
|
||||||
|
controlPanel.style.display = 'flex';
|
||||||
|
ctx.clearRect(0, 0, overlayCanvas.width, overlayCanvas.height);
|
||||||
|
startX = startY = endX = endY = 0;
|
||||||
|
} else {
|
||||||
|
cleanup();
|
||||||
|
resolve(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
function cleanup() {
|
||||||
|
document.removeEventListener('keydown', handleKeydown);
|
||||||
|
container.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('keydown', handleKeydown);
|
||||||
|
};
|
||||||
|
};
|
||||||
|
video.play();
|
||||||
|
}).catch(error => {
|
||||||
|
console.error('Capture failed:', error);
|
||||||
|
resolve(null);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
window.canvasToFile = async function(canvas, filename = null) {
|
||||||
|
console.log('canvasToFile called');
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const name = filename || `screenshot-${Date.now()}.png`;
|
||||||
|
canvas.toBlob((blob) => {
|
||||||
|
const file = new File([blob], name, { type: 'image/png' });
|
||||||
|
resolve(file);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log('✓ Capture tools initialized successfully');
|
||||||
|
console.log('Available: window.captureScreenshot, window.captureSnip, window.canvasToFile');
|
||||||
|
</script>
|
||||||
|
<script src="./tools/screen-record/index.js"></script>
|
||||||
</head>
|
</head>
|
||||||
<body class="bg-gradient-to-br from-primary to-secondary p-4 sm:p-8 flex flex-col items-center justify-center font-sans relative h-screen overflow-hidden">
|
<body class="bg-gradient-to-br from-primary to-secondary p-4 sm:p-8 flex flex-col items-center justify-center font-sans relative h-screen overflow-hidden">
|
||||||
<!-- Top-right display name -->
|
<!-- Top-right display name -->
|
||||||
@@ -172,7 +401,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="space-y-2 relative pb-32">
|
<div class="space-y-2 relative pb-32 -mt-8 gap-2">
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<button id="stopBtn" class="hidden w-10 h-10 flex items-center justify-center rounded-full bg-red-600 text-white shadow disabled:opacity-50" title="Stop recording" disabled>
|
<button id="stopBtn" class="hidden w-10 h-10 flex items-center justify-center rounded-full bg-red-600 text-white shadow disabled:opacity-50" title="Stop recording" disabled>
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
||||||
@@ -210,15 +439,13 @@
|
|||||||
</div>
|
</div>
|
||||||
<div id="recordStatus" class="text-xs text-gray-500"></div>
|
<div id="recordStatus" class="text-xs text-gray-500"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="space-y-2 -mt-8">
|
<div class="space-y-2">
|
||||||
<label class="block text-sm font-medium text-gray-700">Attachments</label>
|
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<button id="attachmentMenuBtn" class="px-3 py-2 rounded border border-gray-300 text-gray-700 hover:bg-gray-100 transition flex items-center gap-2" type="button" title="Add attachment">
|
<button id="attachmentMenuBtn" class="px-2 py-2 rounded border border-gray-300 text-gray-700 hover:bg-gray-100 transition flex items-center gap-2" type="button" title="Add attachment">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="w-5 h-5">
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="w-5 h-5">
|
||||||
<path d="M21.5 2v6h-6M2.5 22v-6h6M2 11.5a10 10 0 0 1 18.8-4.3M22 12.5a10 10 0 0 1-18.8 4.2"/>
|
<path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/>
|
||||||
</svg>
|
</svg>
|
||||||
Attach
|
|
||||||
</button>
|
</button>
|
||||||
<button id="clearAttachmentsBtn" class="px-3 py-1 rounded border border-gray-200 text-gray-700 hover:bg-gray-100 text-sm" type="button">Clear</button>
|
<button id="clearAttachmentsBtn" class="px-3 py-1 rounded border border-gray-200 text-gray-700 hover:bg-gray-100 text-sm" type="button">Clear</button>
|
||||||
<div id="noteAttachmentsSummary" class="text-xs text-gray-600">No attachments selected.</div>
|
<div id="noteAttachmentsSummary" class="text-xs text-gray-600">No attachments selected.</div>
|
||||||
@@ -238,12 +465,18 @@
|
|||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
<div id="imageSubmenu" class="hidden bg-gray-50 border-t border-gray-200">
|
<div id="imageSubmenu" class="hidden bg-gray-50 border-t border-gray-200">
|
||||||
<button id="takePhotoBtn" class="w-full text-left px-6 py-2 text-sm text-gray-700 hover:bg-gray-100 flex items-center gap-2" type="button">
|
<button id="takePhotoBtn" class="mobile-only w-full text-left px-6 py-2 text-sm text-gray-700 hover:bg-gray-100 flex items-center gap-2" type="button">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="w-4 h-4">
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="w-4 h-4">
|
||||||
<path d="M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z"/><circle cx="12" cy="13" r="4"/>
|
<path d="M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z"/><circle cx="12" cy="13" r="4"/>
|
||||||
</svg>
|
</svg>
|
||||||
Take Photo
|
Take Photo
|
||||||
</button>
|
</button>
|
||||||
|
<button id="takeSnipBtn" class="desktop-only hidden w-full text-left px-6 py-2 text-sm text-gray-700 hover:bg-gray-100 flex items-center gap-2" type="button">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="w-4 h-4">
|
||||||
|
<path d="M3 3h6v6H3zM15 3h6v6h-6zM3 15h6v6H3zM15 15h6v6h-6z"/><line x1="9" y1="3" x2="9" y2="24"/><line x1="15" y1="3" x2="15" y2="24"/><line x1="3" y1="9" x2="24" y2="9"/><line x1="3" y1="15" x2="24" y2="15"/>
|
||||||
|
</svg>
|
||||||
|
Screenshot/Clip
|
||||||
|
</button>
|
||||||
<button id="selectImageBtn" class="w-full text-left px-6 py-2 text-sm text-gray-700 hover:bg-gray-100 flex items-center gap-2" type="button">
|
<button id="selectImageBtn" class="w-full text-left px-6 py-2 text-sm text-gray-700 hover:bg-gray-100 flex items-center gap-2" type="button">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="w-4 h-4">
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="w-4 h-4">
|
||||||
<path d="M13 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z"/><polyline points="13 2 13 9 20 9"/>
|
<path d="M13 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z"/><polyline points="13 2 13 9 20 9"/>
|
||||||
@@ -263,12 +496,18 @@
|
|||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
<div id="videoSubmenu" class="hidden bg-gray-50 border-t border-gray-200">
|
<div id="videoSubmenu" class="hidden bg-gray-50 border-t border-gray-200">
|
||||||
<button id="recordVideoBtn" class="w-full text-left px-6 py-2 text-sm text-gray-700 hover:bg-gray-100 flex items-center gap-2" type="button">
|
<button id="recordVideoBtn" class="mobile-only w-full text-left px-6 py-2 text-sm text-gray-700 hover:bg-gray-100 flex items-center gap-2" type="button">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="w-4 h-4">
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="w-4 h-4">
|
||||||
<circle cx="12" cy="13" r="9"/><path d="M12 10v6"/>
|
<circle cx="12" cy="13" r="9"/><path d="M12 10v6"/>
|
||||||
</svg>
|
</svg>
|
||||||
Take Video
|
Take Video
|
||||||
</button>
|
</button>
|
||||||
|
<button id="recordScreenBtn" class="desktop-only hidden w-full text-left px-6 py-2 text-sm text-gray-700 hover:bg-gray-100 flex items-center gap-2" type="button">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="w-4 h-4">
|
||||||
|
<rect x="2" y="3" width="20" height="14" rx="2" ry="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/><circle cx="12" cy="10" r="2"/>
|
||||||
|
</svg>
|
||||||
|
Record Screen
|
||||||
|
</button>
|
||||||
<button id="selectVideoBtn" class="w-full text-left px-6 py-2 text-sm text-gray-700 hover:bg-gray-100 flex items-center gap-2" type="button">
|
<button id="selectVideoBtn" class="w-full text-left px-6 py-2 text-sm text-gray-700 hover:bg-gray-100 flex items-center gap-2" type="button">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="w-4 h-4">
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="w-4 h-4">
|
||||||
<path d="M13 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z"/><polyline points="13 2 13 9 20 9"/>
|
<path d="M13 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z"/><polyline points="13 2 13 9 20 9"/>
|
||||||
@@ -416,14 +655,12 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<label class="block text-sm font-medium text-gray-700">Attachments</label>
|
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<button id="conversationAttachmentMenuBtn" class="px-3 py-2 rounded border border-gray-300 text-gray-700 hover:bg-gray-100 transition flex items-center gap-2" type="button" title="Add attachment">
|
<button id="conversationAttachmentMenuBtn" class="px-2 py-2 rounded border border-gray-300 text-gray-700 hover:bg-gray-100 transition flex items-center gap-2" type="button" title="Add attachment">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="w-5 h-5">
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="w-5 h-5">
|
||||||
<path d="M21.5 2v6h-6M2.5 22v-6h6M2 11.5a10 10 0 0 1 18.8-4.3M22 12.5a10 10 0 0 1-18.8 4.2"/>
|
<path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/>
|
||||||
</svg>
|
</svg>
|
||||||
Attach
|
|
||||||
</button>
|
</button>
|
||||||
<button id="conversationClearAttachmentsBtn" class="px-3 py-1 rounded border border-gray-200 text-gray-700 hover:bg-gray-100 text-sm" type="button">Clear</button>
|
<button id="conversationClearAttachmentsBtn" class="px-3 py-1 rounded border border-gray-200 text-gray-700 hover:bg-gray-100 text-sm" type="button">Clear</button>
|
||||||
<div id="conversationAttachmentsSummary" class="text-xs text-gray-600">No attachments selected.</div>
|
<div id="conversationAttachmentsSummary" class="text-xs text-gray-600">No attachments selected.</div>
|
||||||
@@ -443,12 +680,18 @@
|
|||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
<div id="conversationImageSubmenu" class="hidden bg-gray-50 border-t border-gray-200">
|
<div id="conversationImageSubmenu" class="hidden bg-gray-50 border-t border-gray-200">
|
||||||
<button id="conversationTakePhotoBtn" class="w-full text-left px-6 py-2 text-sm text-gray-700 hover:bg-gray-100 flex items-center gap-2" type="button">
|
<button id="conversationTakePhotoBtn" class="mobile-only w-full text-left px-6 py-2 text-sm text-gray-700 hover:bg-gray-100 flex items-center gap-2" type="button">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="w-4 h-4">
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="w-4 h-4">
|
||||||
<path d="M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z"/><circle cx="12" cy="13" r="4"/>
|
<path d="M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z"/><circle cx="12" cy="13" r="4"/>
|
||||||
</svg>
|
</svg>
|
||||||
Take Photo
|
Take Photo
|
||||||
</button>
|
</button>
|
||||||
|
<button id="conversationTakeSnipBtn" class="desktop-only hidden w-full text-left px-6 py-2 text-sm text-gray-700 hover:bg-gray-100 flex items-center gap-2" type="button">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="w-4 h-4">
|
||||||
|
<path d="M3 3h6v6H3zM15 3h6v6h-6zM3 15h6v6H3zM15 15h6v6h-6z"/><line x1="9" y1="3" x2="9" y2="24"/><line x1="15" y1="3" x2="15" y2="24"/><line x1="3" y1="9" x2="24" y2="9"/><line x1="3" y1="15" x2="24" y2="15"/>
|
||||||
|
</svg>
|
||||||
|
Screenshot/Clip
|
||||||
|
</button>
|
||||||
<button id="conversationSelectImageBtn" class="w-full text-left px-6 py-2 text-sm text-gray-700 hover:bg-gray-100 flex items-center gap-2" type="button">
|
<button id="conversationSelectImageBtn" class="w-full text-left px-6 py-2 text-sm text-gray-700 hover:bg-gray-100 flex items-center gap-2" type="button">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="w-4 h-4">
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="w-4 h-4">
|
||||||
<path d="M13 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z"/><polyline points="13 2 13 9 20 9"/>
|
<path d="M13 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z"/><polyline points="13 2 13 9 20 9"/>
|
||||||
@@ -468,12 +711,18 @@
|
|||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
<div id="conversationVideoSubmenu" class="hidden bg-gray-50 border-t border-gray-200">
|
<div id="conversationVideoSubmenu" class="hidden bg-gray-50 border-t border-gray-200">
|
||||||
<button id="conversationRecordVideoBtn" class="w-full text-left px-6 py-2 text-sm text-gray-700 hover:bg-gray-100 flex items-center gap-2" type="button">
|
<button id="conversationRecordVideoBtn" class="mobile-only w-full text-left px-6 py-2 text-sm text-gray-700 hover:bg-gray-100 flex items-center gap-2" type="button">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="w-4 h-4">
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="w-4 h-4">
|
||||||
<circle cx="12" cy="13" r="9"/><path d="M12 10v6"/>
|
<circle cx="12" cy="13" r="9"/><path d="M12 10v6"/>
|
||||||
</svg>
|
</svg>
|
||||||
Take Video
|
Take Video
|
||||||
</button>
|
</button>
|
||||||
|
<button id="conversationRecordScreenBtn" class="desktop-only hidden w-full text-left px-6 py-2 text-sm text-gray-700 hover:bg-gray-100 flex items-center gap-2" type="button">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="w-4 h-4">
|
||||||
|
<rect x="2" y="3" width="20" height="14" rx="2" ry="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/><circle cx="12" cy="10" r="2"/>
|
||||||
|
</svg>
|
||||||
|
Record Screen
|
||||||
|
</button>
|
||||||
<button id="conversationSelectVideoBtn" class="w-full text-left px-6 py-2 text-sm text-gray-700 hover:bg-gray-100 flex items-center gap-2" type="button">
|
<button id="conversationSelectVideoBtn" class="w-full text-left px-6 py-2 text-sm text-gray-700 hover:bg-gray-100 flex items-center gap-2" type="button">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="w-4 h-4">
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="w-4 h-4">
|
||||||
<path d="M13 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z"/><polyline points="13 2 13 9 20 9"/>
|
<path d="M13 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z"/><polyline points="13 2 13 9 20 9"/>
|
||||||
@@ -1794,6 +2043,19 @@
|
|||||||
const videoCameraInput = document.getElementById('videoCameraInput');
|
const videoCameraInput = document.getElementById('videoCameraInput');
|
||||||
const fileInput = document.getElementById('fileInput');
|
const fileInput = document.getElementById('fileInput');
|
||||||
|
|
||||||
|
// Detect mobile vs desktop and show/hide appropriate buttons
|
||||||
|
const isMobileDevice = () => /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent);
|
||||||
|
const setupDeviceSpecificUI = () => {
|
||||||
|
if (isMobileDevice()) {
|
||||||
|
document.querySelectorAll('.desktop-only').forEach(el => el.classList.add('hidden'));
|
||||||
|
document.querySelectorAll('.mobile-only').forEach(el => el.classList.remove('hidden'));
|
||||||
|
} else {
|
||||||
|
document.querySelectorAll('.mobile-only').forEach(el => el.classList.add('hidden'));
|
||||||
|
document.querySelectorAll('.desktop-only').forEach(el => el.classList.remove('hidden'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
setupDeviceSpecificUI();
|
||||||
|
|
||||||
attachmentMenuBtn.addEventListener('click', (e) => {
|
attachmentMenuBtn.addEventListener('click', (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
attachmentMenu.classList.toggle('hidden');
|
attachmentMenu.classList.toggle('hidden');
|
||||||
@@ -1862,6 +2124,42 @@
|
|||||||
renderNoteAttachmentsList();
|
renderNoteAttachmentsList();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Snip handler (partial screenshot)
|
||||||
|
document.getElementById('takeSnipBtn')?.addEventListener('click', async () => {
|
||||||
|
try {
|
||||||
|
attachmentMenu.classList.add('hidden');
|
||||||
|
imageSubmenu.classList.add('hidden');
|
||||||
|
const canvas = await window.captureSnip();
|
||||||
|
if (canvas) {
|
||||||
|
const file = await window.canvasToFile(canvas);
|
||||||
|
pendingNoteAttachments.push(file);
|
||||||
|
renderNoteAttachmentsList();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Snip failed:', error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Screen recording handler
|
||||||
|
document.getElementById('recordScreenBtn')?.addEventListener('click', async () => {
|
||||||
|
try {
|
||||||
|
attachmentMenu.classList.add('hidden');
|
||||||
|
videoSubmenu.classList.add('hidden');
|
||||||
|
|
||||||
|
const recorder = await window.recordScreen();
|
||||||
|
if (recorder) {
|
||||||
|
// Auto-stop after 5 minutes
|
||||||
|
setTimeout(async () => {
|
||||||
|
const file = await recorder.stop();
|
||||||
|
pendingNoteAttachments.push(file);
|
||||||
|
renderNoteAttachmentsList();
|
||||||
|
}, 5 * 60 * 1000);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Screen recording failed:', error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Close menu on outside click
|
// Close menu on outside click
|
||||||
document.addEventListener('click', (e) => {
|
document.addEventListener('click', (e) => {
|
||||||
if (!attachmentMenuBtn.contains(e.target) && !attachmentMenu.contains(e.target)) {
|
if (!attachmentMenuBtn.contains(e.target) && !attachmentMenu.contains(e.target)) {
|
||||||
@@ -1948,6 +2246,42 @@
|
|||||||
renderConversationAttachmentsList();
|
renderConversationAttachmentsList();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Snip handler for conversation (partial screenshot)
|
||||||
|
document.getElementById('conversationTakeSnipBtn')?.addEventListener('click', async () => {
|
||||||
|
try {
|
||||||
|
conversationAttachmentMenu.classList.add('hidden');
|
||||||
|
conversationImageSubmenu.classList.add('hidden');
|
||||||
|
const canvas = await window.captureSnip();
|
||||||
|
if (canvas) {
|
||||||
|
const file = await window.canvasToFile(canvas);
|
||||||
|
pendingConversationAttachments.push(file);
|
||||||
|
renderConversationAttachmentsList();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Snip failed:', error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Screen recording handler for conversation
|
||||||
|
document.getElementById('conversationRecordScreenBtn')?.addEventListener('click', async () => {
|
||||||
|
try {
|
||||||
|
conversationAttachmentMenu.classList.add('hidden');
|
||||||
|
conversationVideoSubmenu.classList.add('hidden');
|
||||||
|
|
||||||
|
const recorder = await window.recordScreen();
|
||||||
|
if (recorder) {
|
||||||
|
// Auto-stop after 5 minutes
|
||||||
|
setTimeout(async () => {
|
||||||
|
const file = await recorder.stop();
|
||||||
|
pendingConversationAttachments.push(file);
|
||||||
|
renderConversationAttachmentsList();
|
||||||
|
}, 5 * 60 * 1000);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Screen recording failed:', error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Close conversation menu on outside click
|
// Close conversation menu on outside click
|
||||||
document.addEventListener('click', (e) => {
|
document.addEventListener('click', (e) => {
|
||||||
if (!conversationAttachmentMenuBtn.contains(e.target) && !conversationAttachmentMenu.contains(e.target)) {
|
if (!conversationAttachmentMenuBtn.contains(e.target) && !conversationAttachmentMenu.contains(e.target)) {
|
||||||
|
|||||||
@@ -104,8 +104,9 @@ app.post('/api/submit', async (c) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Serve static files (images, css, etc)
|
// Serve static files (images, css, tools, etc)
|
||||||
app.use('/images/*', serveStatic({ root: new URL('./', import.meta.url).pathname }));
|
app.use('/images/*', serveStatic({ root: new URL('./', import.meta.url).pathname }));
|
||||||
|
app.use('/tools/*', serveStatic({ root: new URL('./', import.meta.url).pathname }));
|
||||||
|
|
||||||
// Serve static index.html
|
// Serve static index.html
|
||||||
app.get('/', async (c) => {
|
app.get('/', async (c) => {
|
||||||
|
|||||||
@@ -0,0 +1,147 @@
|
|||||||
|
# Screen Capture Tool
|
||||||
|
|
||||||
|
A standalone utility for capturing screenshots from user's screen using the native Browser Screen Capture API.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- 📸 Capture screenshots from user-selected screen/window
|
||||||
|
- 🎯 No external dependencies
|
||||||
|
- ⚡ Returns canvas for flexible post-processing
|
||||||
|
- 🛡️ Graceful error handling
|
||||||
|
- ♿ Respects user permissions
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
Copy the `screen-capture` folder to your project:
|
||||||
|
|
||||||
|
```
|
||||||
|
your-project/
|
||||||
|
├── tools/
|
||||||
|
│ └── screen-capture/
|
||||||
|
│ ├── index.js
|
||||||
|
│ └── README.md
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
### Basic Screenshot Capture
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
import { captureScreenshot } from './tools/screen-capture/index.js';
|
||||||
|
|
||||||
|
const canvas = await captureScreenshot();
|
||||||
|
if (canvas) {
|
||||||
|
// Canvas is ready for use
|
||||||
|
// Convert to File, display, or manipulate further
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Convert to File
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
import { captureScreenshot, canvasToFile } from './tools/screen-capture/index.js';
|
||||||
|
|
||||||
|
const canvas = await captureScreenshot();
|
||||||
|
if (canvas) {
|
||||||
|
const file = await canvasToFile(canvas, 'my-screenshot.png');
|
||||||
|
// Use file with FormData, attachments, etc.
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### One-liner with Blob
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const canvas = await captureScreenshot();
|
||||||
|
canvas?.toBlob((blob) => {
|
||||||
|
const file = new File([blob], `screenshot-${Date.now()}.png`, { type: 'image/png' });
|
||||||
|
// Use file...
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## API Reference
|
||||||
|
|
||||||
|
### `captureScreenshot()`
|
||||||
|
|
||||||
|
**Returns:** `Promise<Canvas | null>`
|
||||||
|
|
||||||
|
Opens native browser dialog for user to select screen/window to capture. Returns a canvas element containing the screenshot, or null if user cancels or error occurs.
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
```javascript
|
||||||
|
const canvas = await captureScreenshot();
|
||||||
|
```
|
||||||
|
|
||||||
|
### `canvasToFile(canvas, filename?)`
|
||||||
|
|
||||||
|
**Parameters:**
|
||||||
|
- `canvas` (Canvas): Canvas element to convert
|
||||||
|
- `filename` (string, optional): Custom filename. Defaults to `screenshot-{timestamp}.png`
|
||||||
|
|
||||||
|
**Returns:** `Promise<File>`
|
||||||
|
|
||||||
|
Converts canvas to PNG File object for easy handling in file uploads or attachments.
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
```javascript
|
||||||
|
const file = await canvasToFile(canvas, 'custom-name.png');
|
||||||
|
```
|
||||||
|
|
||||||
|
## Browser Support
|
||||||
|
|
||||||
|
Requires browsers supporting the Screen Capture API:
|
||||||
|
- Chrome 72+
|
||||||
|
- Edge 79+
|
||||||
|
- Firefox 66+
|
||||||
|
- Safari 13+
|
||||||
|
|
||||||
|
Note: User must grant permission for each capture request.
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
|
||||||
|
The tool gracefully handles:
|
||||||
|
- User cancellation of the capture dialog
|
||||||
|
- Browser permission denial
|
||||||
|
- Browser incompatibility
|
||||||
|
- Missing canvas/video support
|
||||||
|
|
||||||
|
All errors are logged to console and return null.
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
### React Component
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
import { captureScreenshot, canvasToFile } from './tools/screen-capture/index.js';
|
||||||
|
|
||||||
|
export function ScreenshotButton() {
|
||||||
|
const handleCapture = async () => {
|
||||||
|
const canvas = await captureScreenshot();
|
||||||
|
if (canvas) {
|
||||||
|
const file = await canvasToFile(canvas);
|
||||||
|
console.log('Screenshot captured:', file.name);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return <button onClick={handleCapture}>Take Screenshot</button>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### With Form Submission
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
import { captureScreenshot, canvasToFile } from './tools/screen-capture/index.js';
|
||||||
|
|
||||||
|
const canvas = await captureScreenshot();
|
||||||
|
if (canvas) {
|
||||||
|
const file = await canvasToFile(canvas);
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('screenshot', file);
|
||||||
|
|
||||||
|
await fetch('/api/upload', { method: 'POST', body: formData });
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT - Use freely in your projects
|
||||||
@@ -0,0 +1,245 @@
|
|||||||
|
/**
|
||||||
|
* Screen Capture Tool
|
||||||
|
* Captures a screenshot from the user's screen using the Screen Capture API
|
||||||
|
*
|
||||||
|
* @module ScreenCapture
|
||||||
|
*/
|
||||||
|
|
||||||
|
(function() {
|
||||||
|
console.log('Starting Screen Capture Tool initialization...');
|
||||||
|
|
||||||
|
window.captureScreenshot = async function() {
|
||||||
|
console.log('captureScreenshot called');
|
||||||
|
try {
|
||||||
|
// Request screen capture from user
|
||||||
|
const mediaStream = await navigator.mediaDevices.getDisplayMedia({
|
||||||
|
video: { mediaSource: 'screen' }
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create video element to capture frame
|
||||||
|
const video = document.createElement('video');
|
||||||
|
video.srcObject = mediaStream;
|
||||||
|
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
video.onloadedmetadata = () => {
|
||||||
|
// Create canvas and draw video frame
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.width = video.videoWidth;
|
||||||
|
canvas.height = video.videoHeight;
|
||||||
|
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
ctx.drawImage(video, 0, 0);
|
||||||
|
|
||||||
|
// Stop all media tracks
|
||||||
|
mediaStream.getTracks().forEach(track => track.stop());
|
||||||
|
|
||||||
|
// Return canvas for further processing
|
||||||
|
resolve(canvas);
|
||||||
|
};
|
||||||
|
|
||||||
|
video.play();
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Screenshot capture failed:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.captureSnip = async function() {
|
||||||
|
console.log('captureSnip called');
|
||||||
|
try {
|
||||||
|
// First, get the full screen capture
|
||||||
|
const mediaStream = await navigator.mediaDevices.getDisplayMedia({
|
||||||
|
video: { mediaSource: 'screen' }
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create video element to capture frame
|
||||||
|
const video = document.createElement('video');
|
||||||
|
video.srcObject = mediaStream;
|
||||||
|
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
video.onloadedmetadata = () => {
|
||||||
|
// Capture full screen to canvas
|
||||||
|
const fullCanvas = document.createElement('canvas');
|
||||||
|
fullCanvas.width = video.videoWidth;
|
||||||
|
fullCanvas.height = video.videoHeight;
|
||||||
|
const fullCtx = fullCanvas.getContext('2d');
|
||||||
|
fullCtx.drawImage(video, 0, 0);
|
||||||
|
|
||||||
|
// Stop video tracks
|
||||||
|
mediaStream.getTracks().forEach(track => track.stop());
|
||||||
|
|
||||||
|
// Show selection overlay
|
||||||
|
createSelectionOverlay(fullCanvas, resolve);
|
||||||
|
};
|
||||||
|
|
||||||
|
video.play();
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Snip capture failed:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
function createSelectionOverlay(fullCanvas, resolve) {
|
||||||
|
console.log('createSelectionOverlay called');
|
||||||
|
// Create overlay container
|
||||||
|
const overlay = document.createElement('div');
|
||||||
|
overlay.style.cssText = `
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100vw;
|
||||||
|
height: 100vh;
|
||||||
|
background: rgba(0, 0, 0, 0.3);
|
||||||
|
cursor: crosshair;
|
||||||
|
z-index: 99999;
|
||||||
|
user-select: none;
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Create canvas for drawing selection box
|
||||||
|
const selectionCanvas = document.createElement('canvas');
|
||||||
|
selectionCanvas.style.cssText = `
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
cursor: crosshair;
|
||||||
|
z-index: 100000;
|
||||||
|
`;
|
||||||
|
selectionCanvas.width = window.innerWidth;
|
||||||
|
selectionCanvas.height = window.innerHeight;
|
||||||
|
|
||||||
|
// Scale factor for mapping screen pixels to window pixels
|
||||||
|
const scaleX = fullCanvas.width / window.innerWidth;
|
||||||
|
const scaleY = fullCanvas.height / window.innerHeight;
|
||||||
|
|
||||||
|
let isDrawing = false;
|
||||||
|
let startX = 0;
|
||||||
|
let startY = 0;
|
||||||
|
let endX = 0;
|
||||||
|
let endY = 0;
|
||||||
|
|
||||||
|
const ctx = selectionCanvas.getContext('2d');
|
||||||
|
|
||||||
|
function drawSelection() {
|
||||||
|
ctx.clearRect(0, 0, selectionCanvas.width, selectionCanvas.height);
|
||||||
|
|
||||||
|
if (!isDrawing && (startX === endX && startY === endY)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const x = Math.min(startX, endX);
|
||||||
|
const y = Math.min(startY, endY);
|
||||||
|
const width = Math.abs(endX - startX);
|
||||||
|
const height = Math.abs(endY - startY);
|
||||||
|
|
||||||
|
// Draw selection rectangle
|
||||||
|
ctx.strokeStyle = '#0EA5E9';
|
||||||
|
ctx.lineWidth = 2;
|
||||||
|
ctx.strokeRect(x, y, width, height);
|
||||||
|
|
||||||
|
// Fill with semi-transparent color
|
||||||
|
ctx.fillStyle = 'rgba(14, 165, 233, 0.1)';
|
||||||
|
ctx.fillRect(x, y, width, height);
|
||||||
|
|
||||||
|
// Draw corner handles
|
||||||
|
const handleSize = 8;
|
||||||
|
ctx.fillStyle = '#0EA5E9';
|
||||||
|
ctx.fillRect(x - handleSize / 2, y - handleSize / 2, handleSize, handleSize);
|
||||||
|
ctx.fillRect(x + width - handleSize / 2, y - handleSize / 2, handleSize, handleSize);
|
||||||
|
ctx.fillRect(x - handleSize / 2, y + height - handleSize / 2, handleSize, handleSize);
|
||||||
|
ctx.fillRect(x + width - handleSize / 2, y + height - handleSize / 2, handleSize, handleSize);
|
||||||
|
}
|
||||||
|
|
||||||
|
selectionCanvas.addEventListener('mousedown', (e) => {
|
||||||
|
isDrawing = true;
|
||||||
|
startX = e.clientX;
|
||||||
|
startY = e.clientY;
|
||||||
|
endX = startX;
|
||||||
|
endY = startY;
|
||||||
|
});
|
||||||
|
|
||||||
|
selectionCanvas.addEventListener('mousemove', (e) => {
|
||||||
|
if (isDrawing) {
|
||||||
|
endX = e.clientX;
|
||||||
|
endY = e.clientY;
|
||||||
|
drawSelection();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
selectionCanvas.addEventListener('mouseup', () => {
|
||||||
|
if (isDrawing) {
|
||||||
|
isDrawing = false;
|
||||||
|
|
||||||
|
const x = Math.min(startX, endX);
|
||||||
|
const y = Math.min(startY, endY);
|
||||||
|
const width = Math.abs(endX - startX);
|
||||||
|
const height = Math.abs(endY - startY);
|
||||||
|
|
||||||
|
// Validate selection
|
||||||
|
if (width < 10 || height < 10) {
|
||||||
|
console.warn('Selection too small');
|
||||||
|
cleanup();
|
||||||
|
resolve(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create cropped canvas from full screenshot
|
||||||
|
const croppedCanvas = document.createElement('canvas');
|
||||||
|
croppedCanvas.width = width * scaleX;
|
||||||
|
croppedCanvas.height = height * scaleY;
|
||||||
|
const croppedCtx = croppedCanvas.getContext('2d');
|
||||||
|
|
||||||
|
// Draw cropped section from full canvas
|
||||||
|
croppedCtx.drawImage(
|
||||||
|
fullCanvas,
|
||||||
|
x * scaleX,
|
||||||
|
y * scaleY,
|
||||||
|
width * scaleX,
|
||||||
|
height * scaleY,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
width * scaleX,
|
||||||
|
height * scaleY
|
||||||
|
);
|
||||||
|
|
||||||
|
cleanup();
|
||||||
|
resolve(croppedCanvas);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle escape key
|
||||||
|
const handleKeydown = (e) => {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
cleanup();
|
||||||
|
resolve(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
function cleanup() {
|
||||||
|
document.removeEventListener('keydown', handleKeydown);
|
||||||
|
overlay.remove();
|
||||||
|
selectionCanvas.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('keydown', handleKeydown);
|
||||||
|
document.body.appendChild(overlay);
|
||||||
|
document.body.appendChild(selectionCanvas);
|
||||||
|
|
||||||
|
console.log('Snipping tool active: Draw a rectangle to select area. Press ESC to cancel.');
|
||||||
|
}
|
||||||
|
|
||||||
|
window.canvasToFile = async function(canvas, filename = null) {
|
||||||
|
console.log('canvasToFile called');
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const name = filename || `screenshot-${Date.now()}.png`;
|
||||||
|
canvas.toBlob((blob) => {
|
||||||
|
const file = new File([blob], name, { type: 'image/png' });
|
||||||
|
resolve(file);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log('✓ Screen Capture Tool loaded successfully');
|
||||||
|
console.log('Functions available: window.captureScreenshot, window.captureSnip, window.canvasToFile');
|
||||||
|
})();
|
||||||
@@ -0,0 +1,219 @@
|
|||||||
|
# Screen Record Tool
|
||||||
|
|
||||||
|
A standalone utility for recording the user's screen using the native Browser Screen Capture API and MediaRecorder API.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- 🎬 Record user-selected screen/window
|
||||||
|
- ⏱️ Configurable duration limits (default 5 minutes)
|
||||||
|
- 🎯 No external dependencies
|
||||||
|
- ⚡ Fine-grained control with start/stop callbacks
|
||||||
|
- 🛡️ Graceful error handling
|
||||||
|
- ♿ Respects user permissions
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
Copy the `screen-record` folder to your project:
|
||||||
|
|
||||||
|
```
|
||||||
|
your-project/
|
||||||
|
├── tools/
|
||||||
|
│ └── screen-record/
|
||||||
|
│ ├── index.js
|
||||||
|
│ └── README.md
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
### Basic Screen Recording
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
import { recordScreen } from './tools/screen-record/index.js';
|
||||||
|
|
||||||
|
const recorder = await recordScreen();
|
||||||
|
if (recorder) {
|
||||||
|
// User is recording...
|
||||||
|
|
||||||
|
// Stop after 10 seconds
|
||||||
|
setTimeout(async () => {
|
||||||
|
const file = await recorder.stop();
|
||||||
|
console.log('Recording saved:', file.name);
|
||||||
|
}, 10000);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### With Duration Limit
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const recorder = await recordScreen({
|
||||||
|
maxDuration: 60000 // 1 minute instead of default 5 minutes
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### With Callbacks
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const recorder = await recordScreen({
|
||||||
|
onStart: () => console.log('Recording started'),
|
||||||
|
onStop: () => console.log('Recording stopped')
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Quick Recording (Auto-stop)
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
import { recordScreenFor } from './tools/screen-record/index.js';
|
||||||
|
|
||||||
|
const file = await recordScreenFor(10000); // Record for 10 seconds
|
||||||
|
console.log('Recording saved:', file.name);
|
||||||
|
```
|
||||||
|
|
||||||
|
## API Reference
|
||||||
|
|
||||||
|
### `recordScreen(options?)`
|
||||||
|
|
||||||
|
**Parameters:**
|
||||||
|
- `options` (Object, optional):
|
||||||
|
- `maxDuration` (number): Max recording time in ms. Default: 300000 (5 min)
|
||||||
|
- `mimeType` (string): MIME type. Default: 'video/webm'
|
||||||
|
- `onStart` (Function): Called when recording starts
|
||||||
|
- `onStop` (Function): Called when recording stops
|
||||||
|
|
||||||
|
**Returns:** `Promise<RecorderObject | null>`
|
||||||
|
|
||||||
|
Opens native browser dialog for user to select screen/window to record. Returns a recorder controller object with methods to stop and manage the recording.
|
||||||
|
|
||||||
|
**Recorder Object Methods:**
|
||||||
|
- `stop()` - Stops recording and returns File object
|
||||||
|
- `isRecording()` - Returns boolean for current status
|
||||||
|
- `getDuration()` - Returns duration in milliseconds
|
||||||
|
- `mediaStream` - Reference to underlying MediaStream
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
```javascript
|
||||||
|
const recorder = await recordScreen();
|
||||||
|
const file = await recorder.stop();
|
||||||
|
```
|
||||||
|
|
||||||
|
### `recordScreenFor(duration)`
|
||||||
|
|
||||||
|
**Parameters:**
|
||||||
|
- `duration` (number): Recording duration in milliseconds
|
||||||
|
|
||||||
|
**Returns:** `Promise<File>`
|
||||||
|
|
||||||
|
Convenience function that automatically stops recording after specified duration.
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
```javascript
|
||||||
|
const file = await recordScreenFor(30000); // 30 second recording
|
||||||
|
```
|
||||||
|
|
||||||
|
## Browser Support
|
||||||
|
|
||||||
|
Requires browsers supporting Screen Capture and MediaRecorder APIs:
|
||||||
|
- Chrome 72+
|
||||||
|
- Edge 79+
|
||||||
|
- Firefox 66+
|
||||||
|
- Safari 13+
|
||||||
|
|
||||||
|
Note: User must grant permission for each recording request.
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
|
||||||
|
The tool gracefully handles:
|
||||||
|
- User cancellation of the capture dialog
|
||||||
|
- Browser permission denial
|
||||||
|
- Browser incompatibility
|
||||||
|
- Recording stream interruption
|
||||||
|
- Max duration timeout
|
||||||
|
|
||||||
|
All errors are logged to console and return null.
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
### React Component with UI
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
import { recordScreen } from './tools/screen-record/index.js';
|
||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
export function ScreenRecorderButton() {
|
||||||
|
const [isRecording, setIsRecording] = useState(false);
|
||||||
|
const [recorder, setRecorder] = useState(null);
|
||||||
|
|
||||||
|
const startRecording = async () => {
|
||||||
|
const rec = await recordScreen({
|
||||||
|
onStart: () => setIsRecording(true),
|
||||||
|
onStop: () => setIsRecording(false)
|
||||||
|
});
|
||||||
|
setRecorder(rec);
|
||||||
|
};
|
||||||
|
|
||||||
|
const stopRecording = async () => {
|
||||||
|
if (recorder) {
|
||||||
|
const file = await recorder.stop();
|
||||||
|
console.log('Recording saved:', file.name);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{!isRecording ? (
|
||||||
|
<button onClick={startRecording}>Start Recording</button>
|
||||||
|
) : (
|
||||||
|
<button onClick={stopRecording}>Stop Recording</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### With File Upload
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
import { recordScreen } from './tools/screen-record/index.js';
|
||||||
|
|
||||||
|
const recorder = await recordScreen();
|
||||||
|
if (recorder) {
|
||||||
|
// Wait 15 seconds then stop
|
||||||
|
setTimeout(async () => {
|
||||||
|
const file = await recorder.stop();
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('video', file);
|
||||||
|
|
||||||
|
await fetch('/api/upload-video', {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData
|
||||||
|
});
|
||||||
|
}, 15000);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Auto-stop after Duration
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
import { recordScreenFor } from './tools/screen-record/index.js';
|
||||||
|
|
||||||
|
// Record for exactly 5 seconds
|
||||||
|
const file = await recordScreenFor(5000);
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('video', file);
|
||||||
|
|
||||||
|
await fetch('/api/save-recording', {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## File Output
|
||||||
|
|
||||||
|
Both functions return WebM video files with standardized naming:
|
||||||
|
- Filename format: `screen-recording-{timestamp}.webm`
|
||||||
|
- MIME type: `video/webm`
|
||||||
|
- Compatible with most video players and uploads
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT - Use freely in your projects
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
/**
|
||||||
|
* Screen Record Tool
|
||||||
|
* Records the user's screen using the Screen Capture API and MediaRecorder API
|
||||||
|
*
|
||||||
|
* @module ScreenRecord
|
||||||
|
*/
|
||||||
|
|
||||||
|
(function() {
|
||||||
|
console.log('Loading Screen Record Tool...');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Starts recording the user's selected screen/window
|
||||||
|
* Opens native browser dialog for user to select what to record
|
||||||
|
*
|
||||||
|
* @async
|
||||||
|
* @param {Object} [options={}] - Recording options
|
||||||
|
* @param {number} [options.maxDuration=300000] - Maximum recording duration in milliseconds (default: 5 minutes)
|
||||||
|
* @param {string} [options.mimeType='video/webm'] - MIME type for recording
|
||||||
|
* @param {Function} [options.onStart] - Callback when recording starts
|
||||||
|
* @param {Function} [options.onStop] - Callback when recording stops
|
||||||
|
* @returns {Promise<Object>} Recording controller object with stop() method and events
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const recorder = await window.recordScreen();
|
||||||
|
* // User is recording...
|
||||||
|
*
|
||||||
|
* setTimeout(() => {
|
||||||
|
* const file = await recorder.stop();
|
||||||
|
* console.log('Recording saved:', file.name);
|
||||||
|
* }, 10000);
|
||||||
|
*/
|
||||||
|
window.recordScreen = async function(options = {}) {
|
||||||
|
const {
|
||||||
|
maxDuration = 5 * 60 * 1000, // 5 minutes
|
||||||
|
mimeType = 'video/webm',
|
||||||
|
onStart = null,
|
||||||
|
onStop = null
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Request screen capture from user
|
||||||
|
const mediaStream = await navigator.mediaDevices.getDisplayMedia({
|
||||||
|
video: { mediaSource: 'screen' },
|
||||||
|
audio: false
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create MediaRecorder
|
||||||
|
const mediaRecorder = new MediaRecorder(mediaStream, { mimeType });
|
||||||
|
const chunks = [];
|
||||||
|
let isRecording = true;
|
||||||
|
let timeoutId = null;
|
||||||
|
|
||||||
|
// Collect recording data
|
||||||
|
mediaRecorder.ondataavailable = (event) => {
|
||||||
|
if (event.data.size > 0) {
|
||||||
|
chunks.push(event.data);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle recording stop
|
||||||
|
mediaRecorder.onstop = () => {
|
||||||
|
isRecording = false;
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
mediaStream.getTracks().forEach(track => track.stop());
|
||||||
|
if (onStop) onStop();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Start recording
|
||||||
|
mediaRecorder.start();
|
||||||
|
if (onStart) onStart();
|
||||||
|
|
||||||
|
// Set max duration timeout
|
||||||
|
timeoutId = setTimeout(() => {
|
||||||
|
if (isRecording) {
|
||||||
|
mediaRecorder.stop();
|
||||||
|
}
|
||||||
|
}, maxDuration);
|
||||||
|
|
||||||
|
// Stop if user stops screen share
|
||||||
|
mediaStream.getVideoTracks()[0].onended = () => {
|
||||||
|
if (isRecording) {
|
||||||
|
mediaRecorder.stop();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Return controller object
|
||||||
|
return {
|
||||||
|
/**
|
||||||
|
* Stops the recording and returns the recorded file
|
||||||
|
* @returns {Promise<File>} WebM File object with recorded video
|
||||||
|
*/
|
||||||
|
stop: async () => {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
if (!isRecording) {
|
||||||
|
resolve(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const stopHandler = () => {
|
||||||
|
mediaRecorder.removeEventListener('stop', stopHandler);
|
||||||
|
const blob = new Blob(chunks, { type: mimeType });
|
||||||
|
const file = new File(
|
||||||
|
[blob],
|
||||||
|
`screen-recording-${Date.now()}.webm`,
|
||||||
|
{ type: mimeType }
|
||||||
|
);
|
||||||
|
resolve(file);
|
||||||
|
};
|
||||||
|
|
||||||
|
mediaRecorder.addEventListener('stop', stopHandler);
|
||||||
|
mediaRecorder.stop();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if recording is currently active
|
||||||
|
* @returns {boolean} True if recording is in progress
|
||||||
|
*/
|
||||||
|
isRecording: () => isRecording,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the current recording duration in milliseconds
|
||||||
|
* @returns {number} Duration since recording started
|
||||||
|
*/
|
||||||
|
getDuration: () => mediaRecorder.state === 'recording' ? mediaRecorder.state : 0,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The underlying MediaStream object
|
||||||
|
* @type {MediaStream}
|
||||||
|
*/
|
||||||
|
mediaStream
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Screen recording failed:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Simple helper to start recording and stop after specified duration
|
||||||
|
* Useful for quick recordings with automatic stop
|
||||||
|
*
|
||||||
|
* @async
|
||||||
|
* @param {number} duration - Recording duration in milliseconds
|
||||||
|
* @returns {Promise<File>} WebM File object with recorded video
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const file = await window.recordScreenFor(10000); // Record for 10 seconds
|
||||||
|
* console.log('Recording saved:', file.name);
|
||||||
|
*/
|
||||||
|
window.recordScreenFor = async function(duration) {
|
||||||
|
const recorder = await window.recordScreen();
|
||||||
|
if (!recorder) return null;
|
||||||
|
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
setTimeout(async () => {
|
||||||
|
const file = await recorder.stop();
|
||||||
|
resolve(file);
|
||||||
|
}, duration);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log('Screen Record Tool loaded successfully');
|
||||||
|
})();
|
||||||
Reference in New Issue
Block a user