<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Multi Tool Hub</title>
<style>
:root {
--bg-color: #1E1E2F;
--text-color: #EAEAEA;
--header-bg: #2B2D42;
--accent-color: #FFD700;
--card-bg: #3A3D5B;
--hover-btn-color: #E6C200;
--card-hover-bg: #FFD700;
--card-hover-text: #1E1E2F;
--box-shadow-color: rgba(255, 215, 0, 0.2);
--input-bg: #2B2D42;
--input-border: #4A4C6B;
--error-color: #FF6B6B;
--success-color: #76C7C0;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
background-color: var(--bg-color);
color: var(--text-color);
line-height: 1.6;
transition: background-color 0.3s, color 0.3s;
}
header {
background-color: var(--header-bg);
color: var(--text-color);
padding: 1.5rem 0;
text-align: center;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.3);
margin-bottom: 2rem;
}
header h1 {
font-size: 2.5rem;
font-weight: 600;
}
main {
padding: 0 1rem;
max-width: 1200px;
margin: 0 auto;
}
.tool-grid {
display: grid;
gap: 1.5rem;
grid-template-columns: repeat(3, 1fr);
}
.tool-card {
background-color: var(--card-bg);
padding: 1.5rem;
border-radius: 8px;
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.2);
transition: transform 0.3s ease, background-color 0.3s ease, color 0.3s ease, box-shadow 0.3s ease;
display: flex;
flex-direction: column;
justify-content: space-between;
}
.tool-card:hover {
transform: translateY(-5px);
background-color: var(--card-hover-bg);
color: var(--card-hover-text);
box-shadow: 0 8px 25px var(--box-shadow-color);
}
.tool-card h2 {
font-size: 1.5rem;
margin-bottom: 0.75rem;
color: var(--accent-color); /* Title color different from body text */
}
.tool-card:hover h2 {
color: var(--card-hover-text);
}
.tool-card p {
font-size: 0.95rem;
margin-bottom: 1.5rem;
flex-grow: 1;
}
.tool-card button,
.modal-content button:not(.modal-close-btn),
.tool-ui button {
background-color: var(--accent-color);
color: var(--bg-color);
border: none;
padding: 0.75rem 1.25rem;
font-size: 1rem;
font-weight: bold;
border-radius: 5px;
cursor: pointer;
transition: background-color 0.3s ease, box-shadow 0.3s ease;
align-self: flex-start;
}
.tool-card button:hover,
.modal-content button:not(.modal-close-btn):hover,
.tool-ui button:hover {
background-color: var(--hover-btn-color);
box-shadow: 0 4px 10px var(--box-shadow-color);
}
/* Modal Styles */
.modal {
display: none;
position: fixed;
z-index: 1000;
left: 0;
top: 0;
width: 100%;
height: 100%;
overflow: auto;
background-color: rgba(0, 0, 0, 0.7);
animation: fadeIn 0.3s ease-out;
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
.modal-content {
background-color: var(--header-bg);
margin: 5% auto;
padding: 2rem;
border-radius: 8px;
width: 90%;
max-width: 700px;
box-shadow: 0 5px 20px rgba(0,0,0,0.5);
position: relative;
animation: slideIn 0.3s ease-out;
max-height: 90vh; /* Limit height and enable scroll */
display: flex;
flex-direction: column;
}
@keyframes slideIn {
from { transform: translateY(-30px); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 1px solid var(--card-bg);
padding-bottom: 1rem;
margin-bottom: 1rem;
}
.modal-header h2 {
font-size: 1.8rem;
color: var(--accent-color);
}
.modal-close-btn {
color: var(--text-color);
font-size: 2rem;
font-weight: bold;
background: none;
border: none;
cursor: pointer;
padding: 0.5rem;
line-height: 1;
}
.modal-close-btn:hover {
color: var(--accent-color);
}
.modal-tool-content {
margin-bottom: 1.5rem;
flex-grow: 1; /* Allows content to take available space */
overflow-y: auto; /* Scroll for content if it overflows */
}
.modal-tool-output {
background-color: var(--bg-color);
padding: 1rem;
border-radius: 5px;
margin-top: 1rem;
font-family: 'Courier New', Courier, monospace;
white-space: pre-wrap;
word-wrap: break-word;
max-height: 200px;
overflow-y: auto;
border-left: 3px solid transparent;
}
.modal-tool-output.error {
color: var(--error-color);
border-left-color: var(--error-color);
}
.modal-tool-output.success {
color: var(--success-color);
border-left-color: var(--success-color);
}
/* Tool-specific UI elements */
.tool-ui {
display: flex;
flex-direction: column;
gap: 1rem;
}
.tool-ui label {
font-weight: bold;
margin-bottom: 0.25rem;
display: block;
}
.tool-ui input[type="text"],
.tool-ui input[type="number"],
.tool-ui input[type="date"],
.tool-ui input[type="file"],
.tool-ui textarea,
.tool-ui select {
width: 100%;
padding: 0.75rem;
background-color: var(--input-bg);
color: var(--text-color);
border: 1px solid var(--input-border);
border-radius: 5px;
font-size: 1rem;
}
.tool-ui input[type="file"] {
padding: 0.5rem; /* Specific padding for file input */
}
.tool-ui textarea {
min-height: 100px;
resize: vertical;
}
.tool-ui .options-group {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
align-items: center;
}
.tool-ui .options-group label {
font-weight: normal;
margin-bottom: 0;
display: flex;
align-items: center;
}
.tool-ui .options-group input[type="checkbox"] {
margin-right: 0.5rem;
width: auto;
accent-color: var(--accent-color);
}
.tool-ui input[type="range"] {
width: 100%;
accent-color: var(--accent-color);
}
.tool-ui .output-area {
background-color: var(--bg-color);
padding: 0.75rem;
border-radius: 5px;
min-height: 40px;
border: 1px solid var(--input-border);
word-wrap: break-word;
}
.tool-ui img, .tool-ui canvas, .tool-ui video, .tool-ui audio {
max-width: 100%;
border-radius: 5px;
margin-top: 0.5rem;
}
/* QR Code Canvas (if used) */
#qrCodeCanvas {
border: 1px solid var(--accent-color);
display: block;
margin-top: 1rem;
background-color: white; /* QR codes are typically on white */
}
/* Image cropper specific */
#imageCropperCanvasContainer {
position: relative;
max-width: 100%;
max-height: 400px; /* Limit height */
overflow: hidden; /* Important for canvas sizing */
border: 1px solid var(--accent-color);
cursor: crosshair;
}
#imageCropperCanvas, #imageCropperPreviewCanvas {
display: block; /* remove extra space below canvas */
max-width: 100%;
max-height: 400px;
}
/* Processing Indicator */
.processing-indicator {
position: fixed;
top: 20px;
left: 50%;
transform: translateX(-50%);
background-color: var(--accent-color);
color: var(--bg-color);
padding: 10px 20px;
border-radius: 5px;
box-shadow: 0 2px 10px rgba(0,0,0,0.3);
z-index: 2000;
font-weight: bold;
}
/* Responsive Design */
@media (max-width: 992px) { /* Tablet */
.tool-grid {
grid-template-columns: repeat(2, 1fr);
}
header h1 {
font-size: 2rem;
}
.modal-content {
width: 85%;
margin: 10% auto;
}
}
@media (max-width: 600px) { /* Mobile */
.tool-grid {
grid-template-columns: 1fr;
}
header h1 {
font-size: 1.8rem;
}
.modal-content {
width: 95%;
margin: 15% auto 5% auto; /* More top margin, less bottom */
padding: 1.5rem;
max-height: 85vh;
}
.modal-header h2 {
font-size: 1.5rem;
}
.tool-card button, .modal-content button:not(.modal-close-btn), .tool-ui button {
width: 100%; /* Full width buttons on mobile */
padding: 0.9rem;
}
}
/* For fade-in animation on scroll (optional with IntersectionObserver) */
.tool-card.fade-in {
opacity: 0;
transform: translateY(20px);
transition: opacity 0.5s ease-out, transform 0.5s ease-out;
}
.tool-card.visible {
opacity: 1;
transform: translateY(0);
}
</style>
</head>
<body>
<header>
<h1>Multi Tool Hub</h1>
</header>
<main>
<div id="tool-grid" class="tool-grid">
<!-- Tool cards will be injected here by JavaScript -->
</div>
</main>
<div id="modal" class="modal">
<div class="modal-content">
<div class="modal-header">
<h2 id="modal-title">Tool Title</h2>
<button id="modal-close-btn" class="modal-close-btn">×</button>
</div>
<div id="modal-tool-content" class="modal-tool-content">
<!-- Tool-specific UI will be injected here -->
</div>
<div id="modal-tool-output" class="modal-tool-output">
<!-- Tool-specific output/results -->
</div>
</div>
</div>
<div id="processing-indicator" class="processing-indicator" style="display: none;">
Processing...
</div>
<script>
document.addEventListener('DOMContentLoaded', () => {
const toolGrid = document.getElementById('tool-grid');
const modal = document.getElementById('modal');
const modalTitle = document.getElementById('modal-title');
const modalToolContent = document.getElementById('modal-tool-content');
const modalToolOutput = document.getElementById('modal-tool-output');
const modalCloseBtn = document.getElementById('modal-close-btn');
const processingIndicator = document.getElementById('processing-indicator');
let currentToolCleanup = null; // Function to clean up resources for the current tool
let activeAudioContext = null; // Global audio context for tools that need it
function getAudioContext() {
if (!activeAudioContext || activeAudioContext.state === 'closed') {
activeAudioContext = new (window.AudioContext || window.webkitAudioContext)();
}
return activeAudioContext;
}
// --- Utility Functions ---
function showProcessing(message = "Processing...") {
processingIndicator.textContent = message;
processingIndicator.style.display = 'block';
}
function hideProcessing() {
processingIndicator.style.display = 'none';
}
function showOutput(message, type = 'info') { // type can be 'info', 'success', 'error'
modalToolOutput.innerHTML = ''; // Clear previous
const p = document.createElement('p');
p.textContent = message;
modalToolOutput.appendChild(p);
modalToolOutput.className = 'modal-tool-output'; // Reset classes
if (type === 'error') {
modalToolOutput.classList.add('error');
} else if (type === 'success') {
modalToolOutput.classList.add('success');
}
modalToolOutput.style.display = 'block';
}
function clearOutput() {
modalToolOutput.innerHTML = '';
modalToolOutput.style.display = 'none';
}
function createDownloadLink(blob, filename, linkText = 'Download File') {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
a.textContent = linkText;
a.className = 'tool-download-link';
a.style.display = 'block'; // Make it block for better spacing
a.style.marginTop = '10px';
a.style.padding = '8px 12px';
a.style.backgroundColor = 'var(--accent-color)';
a.style.color = 'var(--bg-color)';
a.style.textDecoration = 'none';
a.style.borderRadius = '5px';
a.style.fontWeight = 'bold';
a.target = '_blank'; // Good practice for downloads
// Check if modalToolOutput already has content, append to it
if(modalToolOutput.style.display !== 'block' || modalToolOutput.innerHTML === ''){
modalToolOutput.style.display = 'block'; // Ensure it's visible
modalToolOutput.innerHTML = ''; // Clear any previous non-download message
}
const container = document.createElement('div');
container.appendChild(a);
modalToolOutput.appendChild(container);
// Consider revoking URL on modal close or after some time
// For now, it relies on browser's default blob URL lifetime or manual cleanup.
}
// --- Tool Definitions ---
const tools = [
{ id: 'imageConverter', title: 'Image Converter', description: 'Convert between JPG, PNG, and WEBP formats using canvas.', init: initImageConverter, ui: createImageConverterUI },
{ id: 'imageCompressor', title: 'Image Compressor', description: 'Compress image file size using canvas and quality settings.', init: initImageCompressor, ui: createImageCompressorUI },
{ id: 'imageCropper', title: 'Image Cropper', description: 'Upload and crop image with preview and export.', init: initImageCropper, ui: createImageCropperUI },
{ id: 'videoConverter', title: 'Video Converter', description: 'Re-encode video to WebM using MediaRecorder (limited browser capabilities).', init: initVideoConverter, ui: createVideoConverterUI },
{ id: 'audioConverter', title: 'Audio to WAV Converter', description: 'Convert various audio formats (MP3, etc.) to WAV using Web Audio API.', init: initAudioConverter, ui: createAudioConverterUI },
{ id: 'audioTrimmer', title: 'Audio Trimmer', description: 'Upload, trim audio based on start/end time, and export trimmed clip as WAV.', init: initAudioTrimmer, ui: createAudioTrimmerUI },
{ id: 'ageCalculator', title: 'Age Calculator', description: 'Input date of birth ? output age in years, months, and days.', init: initAgeCalculator, ui: createAgeCalculatorUI },
{ id: 'emiCalculator', title: 'EMI Calculator', description: 'Input loan amount, interest rate, and duration ? show monthly EMI and total interest.', init: initEMICalculator, ui: createEMICalculatorUI },
{ id: 'sipCalculator', title: 'SIP Calculator', description: 'Input monthly investment, interest rate, duration ? output future value.', init: initSIPCalculator, ui: createSIPCalculatorUI },
{ id: 'qrCodeGenerator', title: 'QR Code Generator', description: 'Enter text or URL ? generate downloadable QR image (using canvas).', init: initQRCodeGenerator, ui: createQRCodeGeneratorUI },
{ id: 'passwordGenerator', title: 'Password Generator', description: 'Generate secure password with length, symbols, numbers options.', init: initPasswordGenerator, ui: createPasswordGeneratorUI },
{ id: 'wordCounter', title: 'Word Counter', description: 'Live count of words, characters, spaces, and reading time.', init: initWordCounter, ui: createWordCounterUI },
{ id: 'base64EncoderDecoder', title: 'Base64 Encoder/Decoder', description: 'Convert plain text to base64 and vice versa.', init: initBase64EncoderDecoder, ui: createBase64EncoderDecoderUI },
{ id: 'colorPicker', title: 'Color Picker Tool', description: 'Pick a color and display HEX, RGB, and HSL values.', init: initColorPicker, ui: createColorPickerUI },
{ id: 'textToSpeech', title: 'Text to Speech', description: 'Enter text and listen to it using SpeechSynthesis API.', init: initTextToSpeech, ui: createTextToSpeechUI },
{ id: 'speechToText', title: 'Speech to Text', description: 'Use microphone to convert voice into text using Web Speech API.', init: initSpeechToText, ui: createSpeechToTextUI },
{ id: 'jsonFormatter', title: 'JSON Formatter', description: 'Paste JSON ? auto-format and validate with error handling.', init: initJSONFormatter, ui: createJSONFormatterUI },
{ id: 'unitConverter', title: 'Unit Converter', description: 'Convert values between units (length, weight, temperature, etc.).', init: initUnitConverter, ui: createUnitConverterUI },
{ id: 'bmiCalculator', title: 'BMI Calculator', description: 'Input weight and height ? show BMI category and value.', init: initBMICalculator, ui: createBMICalculatorUI },
{ id: 'timerStopwatch', title: 'Timer / Stopwatch', description: 'Simple timer and stopwatch with start, stop, reset functionality.', init: initTimerStopwatch, ui: createTimerStopwatchUI },
];
// --- Populate Tool Grid ---
tools.forEach(tool => {
const card = document.createElement('div');
card.className = 'tool-card fade-in'; // Add fade-in class for IntersectionObserver
card.innerHTML = `
<h2>${tool.title}</h2>
<p>${tool.description}</p>
<button data-toolid="${tool.id}">Open Tool</button>
`;
card.querySelector('button').addEventListener('click', () => openModal(tool));
toolGrid.appendChild(card);
});
// --- Modal Handling ---
function openModal(tool) {
if (currentToolCleanup) {
currentToolCleanup();
currentToolCleanup = null;
}
modalTitle.textContent = tool.title;
modalToolContent.innerHTML = tool.ui(); // Create UI elements
clearOutput();
tool.init(); // Initialize tool-specific JS after UI is in DOM
modal.style.display = 'block';
document.body.style.overflow = 'hidden';
if (typeof tool.cleanup === 'function') { // Check if cleanup is defined by the tool
currentToolCleanup = tool.cleanup;
}
}
function closeModal() {
if (currentToolCleanup) {
currentToolCleanup();
currentToolCleanup = null;
}
modal.style.display = 'none';
document.body.style.overflow = 'auto';
modalToolContent.innerHTML = '';
clearOutput();
// Optionally close global audio context if no longer needed by any tool
// if (activeAudioContext && activeAudioContext.state !== 'closed') {
// activeAudioContext.close().then(() => activeAudioContext = null);
// }
}
modalCloseBtn.addEventListener('click', closeModal);
modal.addEventListener('click', (event) => {
if (event.target === modal) {
closeModal();
}
});
document.addEventListener('keydown', (event) => {
if (event.key === 'Escape' && modal.style.display === 'block') {
closeModal();
}
});
// --- Intersection Observer for fade-in animation ---
if ('IntersectionObserver' in window) {
const cards = document.querySelectorAll('.tool-card.fade-in');
const cardObserver = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
entry.target.classList.add('visible');
cardObserver.unobserve(entry.target);
}
});
}, { threshold: 0.1 });
cards.forEach(card => {
cardObserver.observe(card);
});
} else { // Fallback for older browsers
document.querySelectorAll('.tool-card.fade-in').forEach(card => {
card.classList.add('visible');
});
}
// --- Tool Implementations ---
// 1. Image Converter
function createImageConverterUI() {
return `
<div class="tool-ui">
<label for="imgConvFile">Upload Image:</label>
<input type="file" id="imgConvFile" accept="image/jpeg,image/png,image/webp,image/gif">
<label for="imgConvFormat">Convert to:</label>
<select id="imgConvFormat">
<option value="image/png">PNG</option>
<option value="image/jpeg">JPEG</option>
<option value="image/webp">WEBP</option>
</select>
<button id="imgConvButton">Convert</button>
<canvas id="imgConvCanvas" style="display:none;"></canvas>
<img id="imgConvPreview" src="#" alt="Preview" style="max-width:100%; margin-top:10px; display:none;">
</div>
`;
}
function initImageConverter() {
const fileInput = document.getElementById('imgConvFile');
const formatSelect = document.getElementById('imgConvFormat');
const convertBtn = document.getElementById('imgConvButton');
const canvas = document.getElementById('imgConvCanvas');
const ctx = canvas.getContext('2d');
const previewImg = document.getElementById('imgConvPreview');
let originalFileName = '';
let objectURL = null;
fileInput.addEventListener('change', (e) => {
const file = e.target.files[0];
if (file) {
originalFileName = file.name.split('.')[0];
if (objectURL) URL.revokeObjectURL(objectURL); // Revoke previous
objectURL = URL.createObjectURL(file);
previewImg.src = objectURL;
previewImg.style.display = 'block';
clearOutput();
}
});
convertBtn.addEventListener('click', () => {
if (!fileInput.files[0] && !previewImg.src.startsWith('blob:')) { // check if a file is loaded
showOutput('Please upload an image file.', 'error');
return;
}
showProcessing();
const img = new Image();
img.onload = () => {
canvas.width = img.naturalWidth; // Use naturalWidth for original dimensions
canvas.height = img.naturalHeight;
ctx.drawImage(img, 0, 0);
const format = formatSelect.value;
let quality = (format === 'image/jpeg' || format === 'image/webp') ? 0.92 : undefined;
try {
const dataUrl = canvas.toDataURL(format, quality);
const extension = format.split('/')[1];
fetch(dataUrl)
.then(res => res.blob())
.then(blob => {
createDownloadLink(blob, `${originalFileName}_converted.${extension}`, `Download ${extension.toUpperCase()}`);
showOutput(`Image converted successfully to ${extension.toUpperCase()}.`, 'success');
// Update preview with converted image for visual confirmation
if (previewImg.src.startsWith('blob:')) URL.revokeObjectURL(previewImg.src); // Revoke old blob
previewImg.src = dataUrl;
objectURL = null; // The new src is a dataURL, not a blob URL
})
.catch(err => showOutput(`Error creating blob: ${err.message}`, 'error'))
.finally(hideProcessing);
} catch (error) {
showOutput(`Conversion error: ${error.message}. Selected format might not be fully supported by your browser.`, 'error');
hideProcessing();
}
};
img.onerror = () => {
showOutput('Error loading image. Please try a different file or format.', 'error');
hideProcessing();
};
// Ensure img.src is set. If previewImg.src is already a dataURL (from previous conversion), use it.
// Otherwise, use the blob URL.
img.src = previewImg.src;
});
// Assign cleanup function to the tool object if needed by openModal
tools.find(t => t.id === 'imageConverter').cleanup = () => {
if (objectURL) {
URL.revokeObjectURL(objectURL);
objectURL = null;
}
// If previewImg holds a dataURL, no specific cleanup needed for it here.
// Canvas is part of the modal content and will be removed.
};
}
// 2. Image Compressor
function createImageCompressorUI() {
return `
<div class="tool-ui">
<label for="imgCompFile">Upload Image (JPG, PNG, WEBP):</label>
<input type="file" id="imgCompFile" accept="image/jpeg,image/png,image/webp">
<label for="imgCompQuality">Quality (0.1 - 1.0 for JPG/WEBP):</label>
<input type="range" id="imgCompQuality" min="0.1" max="1" step="0.05" value="0.7">
<span id="imgCompQualityValue">0.7</span>
<button id="imgCompButton">Compress</button>
<canvas id="imgCompCanvas" style="display:none;"></canvas>
<img id="imgCompPreview" src="#" alt="Preview" style="max-width:100%; margin-top:10px; display:none;">
<div id="imgCompSizeInfo" class="output-area" style="margin-top:10px;"></div>
</div>
`;
}
function initImageCompressor() {
const fileInput = document.getElementById('imgCompFile');
const qualitySlider = document.getElementById('imgCompQuality');
const qualityValueDisplay = document.getElementById('imgCompQualityValue');
const compressBtn = document.getElementById('imgCompButton');
const canvas = document.getElementById('imgCompCanvas');
const ctx = canvas.getContext('2d');
const previewImg = document.getElementById('imgCompPreview');
const sizeInfo = document.getElementById('imgCompSizeInfo');
let originalFile = null;
let objectURL = null;
fileInput.addEventListener('change', (e) => {
originalFile = e.target.files[0];
if (originalFile) {
if (objectURL) URL.revokeObjectURL(objectURL);
objectURL = URL.createObjectURL(originalFile);
previewImg.src = objectURL;
previewImg.style.display = 'block';
clearOutput();
sizeInfo.textContent = `Original size: ${(originalFile.size / 1024).toFixed(2)} KB`;
}
});
qualitySlider.addEventListener('input', () => {
qualityValueDisplay.textContent = qualitySlider.value;
});
compressBtn.addEventListener('click', () => {
if (!originalFile && !previewImg.src.startsWith('blob:')) {
showOutput('Please upload an image file.', 'error');
return;
}
showProcessing();
const img = new Image();
img.onload = () => {
canvas.width = img.naturalWidth;
canvas.height = img.naturalHeight;
ctx.drawImage(img, 0, 0);
let outputFormat = originalFile ? originalFile.type : 'image/jpeg'; // Fallback if originalFile somehow lost
if (outputFormat !== 'image/jpeg' && outputFormat !== 'image/webp') {
outputFormat = 'image/jpeg';
showOutput('Note: For significant compression of PNG, output is JPEG or WEBP. Canvas PNG compression is lossless.', 'info');
}
const quality = parseFloat(qualitySlider.value);
try {
const dataUrl = canvas.toDataURL(outputFormat, quality);
const extension = outputFormat.split('/')[1];
fetch(dataUrl)
.then(res => res.blob())
.then(blob => {
createDownloadLink(blob, `${(originalFile?.name || 'image').split('.')[0]}_compressed.${extension}`);
const originalSizeKB = originalFile ? (originalFile.size / 1024).toFixed(2) : 'N/A';
const compressedSizeKB = (blob.size / 1024).toFixed(2);
let reductionText = '';
if (originalFile) {
const reductionPercent = ((1 - blob.size / originalFile.size) * 100).toFixed(1);
reductionText = ` (${reductionPercent}% reduction)`;
}
sizeInfo.innerHTML = `Original: ${originalSizeKB} KB<br>Compressed: ${compressedSizeKB} KB${reductionText}`;
showOutput(`Image compressed successfully as ${extension.toUpperCase()}.`, 'success');
if (previewImg.src.startsWith('blob:')) URL.revokeObjectURL(previewImg.src);
previewImg.src = dataUrl;
objectURL = null;
})
.catch(err => showOutput(`Error creating blob: ${err.message}`, 'error'))
.finally(hideProcessing);
} catch (error) {
showOutput(`Compression error: ${error.message}.`, 'error');
hideProcessing();
}
};
img.onerror = () => {
showOutput('Error loading image.', 'error');
hideProcessing();
};
img.src = previewImg.src;
});
tools.find(t => t.id === 'imageCompressor').cleanup = () => {
if (objectURL) {
URL.revokeObjectURL(objectURL);
objectURL = null;
}
};
}
// 3. Image Cropper
function createImageCropperUI() {
return `
<div class="tool-ui">
<label for="imgCropFile">Upload Image:</label>
<input type="file" id="imgCropFile" accept="image/*">
<p style="font-size:0.8em; margin-bottom:5px;">Click and drag on the image to select crop area.</p>
<div id="imageCropperCanvasContainer" style="touch-action: none;"> <!-- touch-action for mobile drag -->
<canvas id="imageCropperCanvas"></canvas>
</div>
<button id="imgCropButton" style="margin-top:10px;">Crop & Download</button>
<h4>Preview:</h4>
<canvas id="imageCropperPreviewCanvas" style="border: 1px solid #555; max-width:200px; max-height:150px;"></canvas>
</div>
`;
}
function initImageCropper() {
const fileInput = document.getElementById('imgCropFile');
const canvas = document.getElementById('imageCropperCanvas');
const ctx = canvas.getContext('2d');
const previewCanvas = document.getElementById('imageCropperPreviewCanvas');
const pCtx = previewCanvas.getContext('2d');
const cropButton = document.getElementById('imgCropButton');
const container = document.getElementById('imageCropperCanvasContainer');
let img = new Image();
let cropRect = { x: 0, y: 0, w: 0, h: 0 };
let isDragging = false;
let startX, startY;
let originalFileName = 'cropped_image';
let originalFileType = 'image/png';
let objectURL = null;
let displayScale = 1; // Scale of displayed image vs natural image
fileInput.addEventListener('change', (e) => {
const file = e.target.files[0];
if (file) {
originalFileName = file.name.split('.')[0];
originalFileType = file.type;
if (objectURL) URL.revokeObjectURL(objectURL);
objectURL = URL.createObjectURL(file);
img.onload = () => {
const containerWidth = container.clientWidth;
const maxContainerHeight = 400;
displayScale = Math.min(containerWidth / img.naturalWidth, maxContainerHeight / img.naturalHeight, 1);
canvas.width = img.naturalWidth * displayScale;
canvas.height = img.naturalHeight * displayScale;
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
cropRect = { x: 0, y: 0, w: 0, h: 0 };
clearPreview();
URL.revokeObjectURL(objectURL); // Revoke after loading into image object
objectURL = null;
};
img.src = objectURL;
clearOutput();
}
});
function drawSelection() {
ctx.clearRect(0,0,canvas.width, canvas.height);
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
if (cropRect.w !== 0 && cropRect.h !== 0) { // Allow zero width/height for initial drag
ctx.strokeStyle = 'rgba(255, 215, 0, 0.8)';
ctx.lineWidth = 2;
ctx.strokeRect(cropRect.x, cropRect.y, cropRect.w, cropRect.h);
ctx.fillStyle = 'rgba(255, 215, 0, 0.2)';
ctx.fillRect(cropRect.x, cropRect.y, cropRect.w, cropRect.h);
}
}
function updatePreview() {
if (Math.abs(cropRect.w) > 0 && Math.abs(cropRect.h) > 0) {
const actualCropRect = normalizeRect(cropRect);
const sourceX = actualCropRect.x / displayScale;
const sourceY = actualCropRect.y / displayScale;
const sourceWidth = actualCropRect.w / displayScale;
const sourceHeight = actualCropRect.h / displayScale;
const maxPreviewSize = 150;
let previewScale = Math.min(maxPreviewSize / sourceWidth, maxPreviewSize / sourceHeight, 1);
previewCanvas.width = sourceWidth * previewScale;
previewCanvas.height = sourceHeight * previewScale;
pCtx.drawImage(img, sourceX, sourceY, sourceWidth, sourceHeight, 0, 0, previewCanvas.width, previewCanvas.height);
} else {
clearPreview();
}
}
function normalizeRect(rect) {
// Handles negative width/height from dragging right-to-left or bottom-to-top
let x = rect.x, y = rect.y, w = rect.w, h = rect.h;
if (w < 0) { x = rect.x + w; w = -w; }
if (h < 0) { y = rect.y + h; h = -h; }
return { x, y, w, h };
}
function clearPreview() {
pCtx.clearRect(0, 0, previewCanvas.width, previewCanvas.height);
previewCanvas.width = 150;
previewCanvas.height = 100;
pCtx.fillStyle = '#2B2D42';
pCtx.fillRect(0,0,previewCanvas.width, previewCanvas.height);
pCtx.fillStyle = '#EAEAEA';
pCtx.textAlign = 'center';
pCtx.textBaseline = 'middle';
pCtx.fillText('Preview', previewCanvas.width/2, previewCanvas.height/2);
}
clearPreview();
function getMousePos(canvasEl, evt) {
const rect = canvasEl.getBoundingClientRect();
const clientX = evt.clientX || (evt.touches && evt.touches[0].clientX);
const clientY = evt.clientY || (evt.touches && evt.touches[0].clientY);
return {
x: clientX - rect.left,
y: clientY - rect.top
};
}
const onDragStart = (e) => {
if (!img.src) return;
e.preventDefault(); // Prevent page scroll on touch
isDragging = true;
const pos = getMousePos(canvas, e);
startX = pos.x;
startY = pos.y;
cropRect = { x: startX, y: startY, w: 0, h: 0 };
};
const onDragMove = (e) => {
if (!isDragging || !img.src) return;
e.preventDefault();
const pos = getMousePos(canvas, e);
const currentX = pos.x;
const currentY = pos.y;
// Clamp coordinates to canvas boundaries
cropRect.w = Math.max(-startX, Math.min(currentX - startX, canvas.width - startX));
cropRect.h = Math.max(-startY, Math.min(currentY - startY, canvas.height - startY));
drawSelection();
};
const onDragEnd = () => {
if (!isDragging || !img.src) return;
isDragging = false;
// Normalize and clamp the final rectangle
cropRect = normalizeRect(cropRect);
cropRect.x = Math.max(0, cropRect.x);
cropRect.y = Math.max(0, cropRect.y);
cropRect.w = Math.min(cropRect.w, canvas.width - cropRect.x);
cropRect.h = Math.min(cropRect.h, canvas.height - cropRect.y);
drawSelection();
updatePreview();
};
canvas.addEventListener('mousedown', onDragStart);
canvas.addEventListener('mousemove', onDragMove);
canvas.addEventListener('mouseup', onDragEnd);
canvas.addEventListener('mouseleave', onDragEnd); // End drag if mouse leaves
canvas.addEventListener('touchstart', onDragStart, { passive: false });
canvas.addEventListener('touchmove', onDragMove, { passive: false });
canvas.addEventListener('touchend', onDragEnd);
canvas.addEventListener('touchcancel', onDragEnd);
cropButton.addEventListener('click', () => {
const finalCropRect = normalizeRect(cropRect);
if (!img.src || finalCropRect.w === 0 || finalCropRect.h === 0) {
showOutput('Please upload an image and select a crop area.', 'error');
return;
}
showProcessing();
const tempCanvas = document.createElement('canvas');
const tCtx = tempCanvas.getContext('2d');
const sourceX = finalCropRect.x / displayScale;
const sourceY = finalCropRect.y / displayScale;
const sourceWidth = finalCropRect.w / displayScale;
const sourceHeight = finalCropRect.h / displayScale;
tempCanvas.width = sourceWidth;
tempCanvas.height = sourceHeight;
tCtx.drawImage(img, sourceX, sourceY, sourceWidth, sourceHeight, 0, 0, sourceWidth, sourceHeight);
try {
const dataUrl = tempCanvas.toDataURL(originalFileType, originalFileType.includes('jpeg') ? 0.92 : undefined);
const extension = (originalFileType.split('/')[1] || 'png').replace('jpeg', 'jpg');
fetch(dataUrl)
.then(res => res.blob())
.then(blob => {
createDownloadLink(blob, `${originalFileName}_cropped.${extension}`);
showOutput('Image cropped successfully.', 'success');
})
.catch(err => showOutput(`Error creating blob: ${err.message}`, 'error'))
.finally(hideProcessing);
} catch (error) {
showOutput(`Cropping error: ${error.message}`, 'error');
hideProcessing();
}
});
tools.find(t => t.id === 'imageCropper').cleanup = () => {
if (objectURL) { // Should be null if img.onload completed
URL.revokeObjectURL(objectURL);
objectURL = null;
}
img.src = ''; // Release image resources
};
}
// 4. Video Converter (Re-encoder to WebM)
function createVideoConverterUI() {
return `
<div class="tool-ui">
<label for="vidConvFile">Upload Video (MP4, etc.):</label>
<input type="file" id="vidConvFile" accept="video/*">
<p style="font-size:0.8em; margin-bottom:5px;">This tool attempts to re-encode the video to WebM (VP8/Opus or VP9/Opus). Conversion can be slow for large files. Ensure video plays before converting.</p>
<button id="vidConvButton" disabled>Convert to WebM</button>
<video id="vidConvPreview" controls style="max-width:100%; margin-top:10px; display:none; background-color:black;"></video>
</div>
`;
}
function initVideoConverter() {
const fileInput = document.getElementById('vidConvFile');
const convertBtn = document.getElementById('vidConvButton');
const videoPreview = document.getElementById('vidConvPreview');
let mediaRecorder;
let recordedChunks = [];
let sourceVideoURL = null;
let streamToStop = null;
fileInput.addEventListener('change', (e) => {
const file = e.target.files[0];
if (file) {
if (sourceVideoURL) URL.revokeObjectURL(sourceVideoURL);
sourceVideoURL = URL.createObjectURL(file);
videoPreview.src = sourceVideoURL;
videoPreview.style.display = 'block';
convertBtn.disabled = false;
clearOutput();
}
});
convertBtn.addEventListener('click', async () => {
if (!videoPreview.src || !videoPreview.captureStream) {
showOutput('Please upload a video or your browser does not support captureStream/MediaRecorder.', 'error');
return;
}
if (!MediaRecorder) {
showOutput('MediaRecorder API not supported by your browser.', 'error');
return;
}
showProcessing('Preparing conversion...');
convertBtn.disabled = true;
recordedChunks = [];
const mimeTypes = [
'video/webm;codecs=vp9,opus',
'video/webm;codecs=vp8,opus',
'video/webm;codecs=vp9', // some browsers might accept this
'video/webm;codecs=vp8',
'video/webm'
];
let supportedMimeType = mimeTypes.find(type => MediaRecorder.isTypeSupported(type));
if (!supportedMimeType) {
showOutput('No supported WebM MIME type found for MediaRecorder.', 'error');
hideProcessing();
convertBtn.disabled = false;
return;
}
showOutput(`Using MIME type: ${supportedMimeType}`, 'info');
try {
streamToStop = videoPreview.captureStream();
mediaRecorder = new MediaRecorder(streamToStop, { mimeType: supportedMimeType });
mediaRecorder.ondataavailable = (event) => {
if (event.data.size > 0) {
recordedChunks.push(event.data);
}
};
mediaRecorder.onstop = () => {
const blob = new Blob(recordedChunks, { type: supportedMimeType });
const originalFileName = (fileInput.files[0]?.name || 'video').split('.')[0];
createDownloadLink(blob, `${originalFileName}_converted.webm`);
showOutput(`Video re-encoded to WebM. Size: ${(blob.size / (1024*1024)).toFixed(2)} MB.`, 'success');
hideProcessing();
convertBtn.disabled = false; // Re-enable after processing
if (streamToStop) streamToStop.getTracks().forEach(track => track.stop());
streamToStop = null;
videoPreview.pause();
videoPreview.currentTime = 0;
};
mediaRecorder.onerror = (event) => {
let errorName = event.error ? event.error.name : 'Unknown error';
showOutput(`MediaRecorder error: ${errorName}`, 'error');
hideProcessing();
convertBtn.disabled = false;
if (streamToStop) streamToStop.getTracks().forEach(track => track.stop());
streamToStop = null;
};
videoPreview.onended = () => { // When original video finishes playing
if (mediaRecorder && mediaRecorder.state === "recording") {
mediaRecorder.stop();
}
};
videoPreview.onloadedmetadata = () => {
videoPreview.play().then(() => {
if(mediaRecorder.state !== "recording"){ mediaRecorder.start(); }
showProcessing('Re-encoding... Please wait. This may take time.');
}).catch(e => {
showOutput(`Error playing video for capture: ${e.message}`, 'error');
hideProcessing();
convertBtn.disabled = false;
if (streamToStop) streamToStop.getTracks().forEach(track => track.stop());
streamToStop = null;
});
};
if (videoPreview.readyState >= videoPreview.HAVE_METADATA) { // HAVE_METADATA = 2
videoPreview.play().then(() => {
if(mediaRecorder.state !== "recording"){ mediaRecorder.start(); }
showProcessing('Re-encoding... Please wait. This may take time.');
}).catch(e => {
showOutput(`Error playing video for capture: ${e.message}`, 'error');
hideProcessing();
convertBtn.disabled = false;
if (streamToStop) streamToStop.getTracks().forEach(track => track.stop());
streamToStop = null;
});
} // else onloadedmetadata will handle it.
} catch (error) {
showOutput(`Error setting up MediaRecorder: ${error.message}`, 'error');
hideProcessing();
convertBtn.disabled = false;
if (streamToStop) streamToStop.getTracks().forEach(track => track.stop());
streamToStop = null;
}
});
tools.find(t => t.id === 'videoConverter').cleanup = () => {
if (mediaRecorder && mediaRecorder.state === "recording") {
mediaRecorder.stop(); // This will trigger onstop
} else if (streamToStop) {
streamToStop.getTracks().forEach(track => track.stop());
}
streamToStop = null;
if (videoPreview.src && videoPreview.src.startsWith('blob:')) {
videoPreview.pause();
// Check if srcObject is used, though for blob URLs it's usually .src
const currentStream = videoPreview.srcObject;
if (currentStream && typeof currentStream.getTracks === 'function') {
currentStream.getTracks().forEach(track => track.stop());
}
URL.revokeObjectURL(videoPreview.src);
videoPreview.srcObject = null;
videoPreview.src = '';
}
if (sourceVideoURL) { //This is the original file blob
URL.revokeObjectURL(sourceVideoURL);
sourceVideoURL = null;
}
recordedChunks = [];
};
}
// 5. Audio to WAV Converter
function createAudioConverterUI() {
return `
<div class="tool-ui">
<label for="audioConvFile">Upload Audio File (MP3, WAV, OGG, etc.):</label>
<input type="file" id="audioConvFile" accept="audio/*">
<button id="audioConvButton">Convert to WAV</button>
<audio id="audioConvPreview" controls style="width:100%; margin-top:10px; display:none;"></audio>
</div>
`;
}
function initAudioConverter() {
const fileInput = document.getElementById('audioConvFile');
const convertBtn = document.getElementById('audioConvButton');
const audioPreview = document.getElementById('audioConvPreview');
let audioFile = null;
let objectURL = null;
fileInput.addEventListener('change', (e) => {
audioFile = e.target.files[0];
if (audioFile) {
if (objectURL) URL.revokeObjectURL(objectURL);
objectURL = URL.createObjectURL(audioFile);
audioPreview.src = objectURL;
audioPreview.style.display = 'block';
clearOutput();
}
});
convertBtn.addEventListener('click', async () => {
if (!audioFile) {
showOutput('Please upload an audio file.', 'error');
return;
}
showProcessing();
const audioCtx = getAudioContext();
const reader = new FileReader();
reader.onload = async (e_reader) => {
try {
const audioBuffer = await audioCtx.decodeAudioData(e_reader.target.result);
const wavBlob = audioBufferToWav(audioBuffer);
const originalFileName = audioFile.name.split('.')[0];
createDownloadLink(wavBlob, `${originalFileName}_converted.wav`);
showOutput('Audio converted to WAV successfully.', 'success');
} catch (err) {
showOutput(`Error converting audio: ${err.message}. Ensure the file is a valid audio format.`, 'error');
} finally {
hideProcessing();
}
};
reader.onerror = () => {
showOutput('Error reading file.', 'error');
hideProcessing();
};
reader.readAsArrayBuffer(audioFile);
});
tools.find(t => t.id === 'audioConverter').cleanup = () => {
if (objectURL) {
URL.revokeObjectURL(objectURL);
objectURL = null;
}
if (audioPreview.src) {
audioPreview.pause();
audioPreview.src = '';
}
};
}
// Helper: AudioBuffer to WAV
function audioBufferToWav(buffer) {
let numOfChan = buffer.numberOfChannels,
btwLength = buffer.length * numOfChan * 2 + 44, // 2 bytes per sample (16-bit)
btwArrBuff = new ArrayBuffer(btwLength),
btwView = new DataView(btwArrBuff),
btwChnls = [],
btwIndex,
btwSample,
btwOffset = 0,
btwPos = 0;
setUint32(0x46464952); // "RIFF"
setUint32(btwLength - 8); // file length - 8
setUint32(0x45564157); // "WAVE"
setUint32(0x20746d66); // "fmt " chunk
setUint32(16); // length = 16 for PCM
setUint16(1); // PCM (uncompressed)
setUint16(numOfChan);
setUint32(buffer.sampleRate);
setUint32(buffer.sampleRate * 2 * numOfChan); // avg. bytes/sec (SR * BytesPerSample * NumChannels)
setUint16(numOfChan * 2); // block-align (BytesPerSample * NumChannels)
setUint16(16); // 16-bit
setUint32(0x61746164); // "data" - chunk
setUint32(buffer.length * numOfChan * 2); // chunk length (NumSamples * NumChannels * BytesPerSample)
for (btwIndex = 0; btwIndex < buffer.numberOfChannels; btwIndex++)
btwChnls.push(buffer.getChannelData(btwIndex));
// Interleave channels and convert to 16-bit PCM
for (btwOffset = 0; btwOffset < buffer.length; btwOffset++) {
for (btwIndex = 0; btwIndex < numOfChan; btwIndex++) {
btwSample = Math.max(-1, Math.min(1, btwChnls[btwIndex][btwOffset])); // clamp
// scale to 16-bit signed int
btwSample = btwSample < 0 ? btwSample * 0x8000 : btwSample * 0x7FFF;
btwView.setInt16(btwPos, btwSample, true); // write 16-bit sample, little-endian
btwPos += 2;
}
}
return new Blob([btwArrBuff], { type: "audio/wav" });
function setUint16(data) {
btwView.setUint16(btwPos, data, true); btwPos += 2;
}
function setUint32(data) {
btwView.setUint32(btwPos, data, true); btwPos += 4;
}
}
// 6. Audio Trimmer
function createAudioTrimmerUI() {
return `
<div class="tool-ui">
<label for="audioTrimFile">Upload Audio File:</label>
<input type="file" id="audioTrimFile" accept="audio/*">
<audio id="audioTrimPreview" controls style="width:100%; margin-top:10px; display:none;"></audio>
<div style="display:flex; gap:10px; margin-top:10px;">
<div style="flex:1;">
<label for="audioTrimStart">Start Time (s):</label>
<input type="number" id="audioTrimStart" value="0" min="0" step="0.01">
</div>
<div style="flex:1;">
<label for="audioTrimEnd">End Time (s):</label>
<input type="number" id="audioTrimEnd" value="0" min="0" step="0.01">
</div>
</div>
<button id="audioTrimButton" style="margin-top:10px;">Trim and Download WAV</button>
</div>
`;
}
function initAudioTrimmer() {
const fileInput = document.getElementById('audioTrimFile');
const preview = document.getElementById('audioTrimPreview');
const startTimeInput = document.getElementById('audioTrimStart');
const endTimeInput = document.getElementById('audioTrimEnd');
const trimBtn = document.getElementById('audioTrimButton');
let audioFile = null;
let originalAudioBuffer = null; // Store the full decoded buffer
let objectURL = null;
fileInput.addEventListener('change', async (e) => {
audioFile = e.target.files[0];
if (audioFile) {
if (objectURL) URL.revokeObjectURL(objectURL);
objectURL = URL.createObjectURL(audioFile);
preview.src = objectURL;
preview.style.display = 'block';
clearOutput();
originalAudioBuffer = null; // Reset buffer
showOutput('Decoding audio... please wait.', 'info');
const audioCtx = getAudioContext();
const reader = new FileReader();
reader.onload = async (ev_reader) => {
try {
originalAudioBuffer = await audioCtx.decodeAudioData(ev_reader.target.result);
endTimeInput.value = originalAudioBuffer.duration.toFixed(2);
startTimeInput.max = originalAudioBuffer.duration.toFixed(2);
endTimeInput.max = originalAudioBuffer.duration.toFixed(2);
showOutput(`Audio loaded. Duration: ${originalAudioBuffer.duration.toFixed(2)}s. Ready to trim.`, 'success');
} catch (err) {
showOutput(`Error decoding audio: ${err.message}`, 'error');
originalAudioBuffer = null;
} finally {
// No explicit hideProcessing as decode is part of loading user flow
}
};
reader.readAsArrayBuffer(audioFile);
}
});
preview.onloadedmetadata = () => { // Fallback if decodeAudioData is slow or fails
if (!originalAudioBuffer && preview.duration) {
endTimeInput.value = preview.duration.toFixed(2);
startTimeInput.max = preview.duration.toFixed(2);
endTimeInput.max = preview.duration.toFixed(2);
if (!modalToolOutput.textContent.includes('success')) { // Avoid overwriting decode success
showOutput(`Preview loaded. Duration: ${preview.duration.toFixed(2)}s. For trimming, full decode is preferred.`, 'info');
}
}
};
trimBtn.addEventListener('click', () => {
if (!originalAudioBuffer) {
showOutput('Please upload and wait for audio to fully decode before trimming.', 'error');
return;
}
const audioCtx = getAudioContext(); // Ensure context is active
const startTime = parseFloat(startTimeInput.value);
const endTime = parseFloat(endTimeInput.value);
if (isNaN(startTime) || isNaN(endTime) || startTime < 0 || endTime <= startTime || endTime > originalAudioBuffer.duration) {
showOutput('Invalid start or end time. Ensure End Time > Start Time and within audio duration.', 'error');
return;
}
showProcessing('Trimming audio...');
try {
const startSample = Math.floor(startTime * originalAudioBuffer.sampleRate);
const endSample = Math.floor(endTime * originalAudioBuffer.sampleRate);
const trimmedLength = endSample - startSample;
if (trimmedLength <= 0) {
showOutput('Trimmed duration is zero or negative.', 'error');
hideProcessing();
return;
}
const trimmedBuffer = audioCtx.createBuffer(
originalAudioBuffer.numberOfChannels,
trimmedLength,
originalAudioBuffer.sampleRate
);
for (let i = 0; i < originalAudioBuffer.numberOfChannels; i++) {
const channelData = originalAudioBuffer.getChannelData(i);
const trimmedChannelData = trimmedBuffer.getChannelData(i);
// Use subarray for efficiency if available and correct, otherwise loop
// For AudioBuffer, getChannelData returns a Float32Array, which has subarray
trimmedChannelData.set(channelData.subarray(startSample, endSample));
}
const wavBlob = audioBufferToWav(trimmedBuffer); // Use the helper
const originalFileName = audioFile.name.split('.')[0];
createDownloadLink(wavBlob, `${originalFileName}_trimmed.wav`);
showOutput('Audio trimmed successfully.', 'success');
} catch (err) {
showOutput(`Error trimming audio: ${err.message}`, 'error');
} finally {
hideProcessing();
}
});
tools.find(t => t.id === 'audioTrimmer').cleanup = () => {
if (objectURL) {
URL.revokeObjectURL(objectURL);
objectURL = null;
}
if (preview.src) {
preview.pause();
preview.src = '';
}
originalAudioBuffer = null; // Release the decoded buffer
};
}
// 7. Age Calculator
function createAgeCalculatorUI() {
return `
<div class="tool-ui">
<label for="dob">Date of Birth:</label>
<input type="date" id="dob" max="${new Date().toISOString().split('T')[0]}">
<button id="calculateAgeBtn">Calculate Age</button>
<div id="ageResult" class="output-area" style="margin-top:10px;"></div>
</div>
`;
}
function initAgeCalculator() {
const dobInput = document.getElementById('dob');
const calculateBtn = document.getElementById('calculateAgeBtn');
const ageResultDiv = document.getElementById('ageResult');
calculateBtn.addEventListener('click', () => {
const dobString = dobInput.value;
ageResultDiv.textContent = ''; // Clear previous result
clearOutput();
if (!dobString) {
ageResultDiv.textContent = 'Please select a date of birth.';
ageResultDiv.style.color = 'var(--error-color)';
showOutput('Please select a date of birth.', 'error');
return;
}
const dob = new Date(dobString);
const today = new Date();
if (dob > today) {
ageResultDiv.textContent = 'Date of birth cannot be in the future.';
ageResultDiv.style.color = 'var(--error-color)';
showOutput('Date of birth cannot be in the future.', 'error');
return;
}
let years = today.getFullYear() - dob.getFullYear();
let months = today.getMonth() - dob.getMonth();
let days = today.getDate() - dob.getDate();
if (days < 0) {
months--;
const lastMonthDate = new Date(today.getFullYear(), today.getMonth(), 0).getDate();
days += lastMonthDate;
}
if (months < 0) {
years--;
months += 12;
}
const resultText = `You are ${years} years, ${months} months, and ${days} days old.`;
ageResultDiv.textContent = resultText;
ageResultDiv.style.color = 'var(--text-color)';
showOutput(resultText, 'success');
});
}
// 8. EMI Calculator
function createEMICalculatorUI() {
return `
<div class="tool-ui">
<label for="loanAmount">Loan Amount (?):</label>
<input type="number" id="loanAmount" placeholder="e.g., 500000" min="0">
<label for="interestRate">Annual Interest Rate (%):</label>
<input type="number" id="interestRate" placeholder="e.g., 8.5" step="0.01" min="0">
<label for="loanTenure">Loan Tenure (Years):</label>
<input type="number" id="loanTenure" placeholder="e.g., 5" min="0">
<button id="calculateEMIBtn">Calculate EMI</button>
<div id="emiResult" class="output-area" style="margin-top:10px;"></div>
</div>
`;
}
function initEMICalculator() {
const amountInput = document.getElementById('loanAmount');
const rateInput = document.getElementById('interestRate');
const tenureInput = document.getElementById('loanTenure');
const calculateBtn = document.getElementById('calculateEMIBtn');
const emiResultDiv = document.getElementById('emiResult');
calculateBtn.addEventListener('click', () => {
emiResultDiv.innerHTML = ''; clearOutput();
const P = parseFloat(amountInput.value);
const annualRate = parseFloat(rateInput.value);
const tenureYears = parseFloat(tenureInput.value);
if (isNaN(P) || P <= 0 || isNaN(annualRate) || annualRate <= 0 || isNaN(tenureYears) || tenureYears <= 0) {
emiResultDiv.textContent = 'Please enter valid positive values for all fields.';
showOutput('Invalid input for EMI calculation.', 'error');
return;
}
const r = (annualRate / 12) / 100; // Monthly interest rate
const n = tenureYears * 12; // Tenure in months
// EMI = P * r * (1+r)^n / ((1+r)^n - 1)
const emi = (P * r * Math.pow(1 + r, n)) / (Math.pow(1 + r, n) - 1);
if (isNaN(emi) || !isFinite(emi)) {
emiResultDiv.textContent = 'Could not calculate EMI. Check inputs (e.g., rate might be too low for formula stability).';
showOutput('EMI calculation resulted in an invalid number.', 'error');
return;
}
const totalPayment = emi * n;
const totalInterest = totalPayment - P;
emiResultDiv.innerHTML = `
Monthly EMI: <strong>? ${emi.toFixed(2)}</strong><br>
Total Interest Payable: <strong>? ${totalInterest.toFixed(2)}</strong><br>
Total Payment (Principal + Interest): <strong>? ${totalPayment.toFixed(2)}</strong>
`;
showOutput('EMI calculation successful.', 'success');
});
}
// 9. SIP Calculator
function createSIPCalculatorUI() {
return `
<div class="tool-ui">
<label for="monthlyInvestment">Monthly Investment (?):</label>
<input type="number" id="monthlyInvestment" placeholder="e.g., 5000" min="0">
<label for="expectedReturnRate">Expected Annual Return Rate (%):</label>
<input type="number" id="expectedReturnRate" placeholder="e.g., 12" step="0.01" min="0">
<label for="investmentDuration">Investment Duration (Years):</label>
<input type="number" id="investmentDuration" placeholder="e.g., 10" min="0">
<button id="calculateSIPBtn">Calculate Future Value</button>
<div id="sipResult" class="output-area" style="margin-top:10px;"></div>
</div>
`;
}
function initSIPCalculator() {
const monthlyInvestmentInput = document.getElementById('monthlyInvestment');
const returnRateInput = document.getElementById('expectedReturnRate');
const durationInput = document.getElementById('investmentDuration');
const calculateBtn = document.getElementById('calculateSIPBtn');
const sipResultDiv = document.getElementById('sipResult');
calculateBtn.addEventListener('click', () => {
sipResultDiv.innerHTML = ''; clearOutput();
const P = parseFloat(monthlyInvestmentInput.value);
const annualRate = parseFloat(returnRateInput.value);
const T = parseFloat(durationInput.value);
if (isNaN(P) || P <= 0 || isNaN(annualRate) || annualRate < 0 || isNaN(T) || T <= 0) { // Rate can be 0
sipResultDiv.textContent = 'Please enter valid positive values for investment and duration, and a non-negative rate.';
showOutput('Invalid input for SIP calculation.', 'error');
return;
}
const i = (annualRate / 12) / 100; // Monthly rate of return
const n = T * 12; // Number of months
// Future Value of SIP: FV = P * [((1+i)^n - 1) / i] * (1+i) (investment at beginning of month)
// If i is 0, the formula ( (1+i)^n - 1 ) / i becomes n
let M;
if (i === 0) {
M = P * n; // Simple sum if rate is 0
} else {
M = P * ( (Math.pow(1 + i, n) - 1) / i ) * (1 + i);
}
const totalInvested = P * n;
const wealthGained = M - totalInvested;
sipResultDiv.innerHTML = `
Invested Amount: <strong>? ${totalInvested.toFixed(2)}</strong><br>
Estimated Wealth Gained: <strong>? ${wealthGained.toFixed(2)}</strong><br>
Total Future Value: <strong>? ${M.toFixed(2)}</strong>
`;
showOutput('SIP calculation successful.', 'success');
});
}
// 10. QR Code Generator
// The qrcode.js library (MIT license) logic is embedded here directly.
// Source: https://github.com/kazuhikoarase/qrcode-generator (Adapted for this tool)
var qrcode = function() {
var qrcode = function(typeNumber, errorCorrectLevel) {
this.typeNumber = typeNumber; this.errorCorrectLevel = errorCorrectLevel; this.modules = null; this.moduleCount = 0; this.dataCache = null; this.dataList = [];
};
qrcode.prototype = {
addData: function(data) { this.dataList.push(new QR8bitByte(data)); this.dataCache = null; },
isDark: function(row, col) { if (row < 0 || this.moduleCount <= row || col < 0 || this.moduleCount <= col) throw new Error(row + "," + col); return this.modules[row][col]; },
getModuleCount: function() { return this.moduleCount; },
make: function() { this.makeImpl(false, this.getBestMaskPattern()); },
makeImpl: function(test, maskPattern) {
this.moduleCount = this.typeNumber * 4 + 17; this.modules = new Array(this.moduleCount);
for (var r = 0; r < this.moduleCount; r++) { this.modules[r] = new Array(this.moduleCount); for (var c = 0; c < this.moduleCount; c++) this.modules[r][c] = null; }
this.setupPositionProbePattern(0, 0); this.setupPositionProbePattern(this.moduleCount - 7, 0); this.setupPositionProbePattern(0, this.moduleCount - 7);
this.setupPositionAdjustPattern(); this.setupTimingPattern(); this.setupTypeInfo(test, maskPattern);
if (this.typeNumber >= 7) this.setupTypeNumber(test);
if (this.dataCache == null) this.dataCache = qrcode.createData(this.typeNumber, this.errorCorrectLevel, this.dataList);
this.mapData(this.dataCache, maskPattern);
},
setupPositionProbePattern: function(row, col) {
for (var r = -1; r <= 7; r++) { if (row + r <= -1 || this.moduleCount <= row + r) continue;
for (var c = -1; c <= 7; c++) { if (col + c <= -1 || this.moduleCount <= col + c) continue;
if ((0 <= r && r <= 6 && (c == 0 || c == 6)) || (0 <= c && c <= 6 && (r == 0 || r == 6)) || (2 <= r && r <= 4 && 2 <= c && c <= 4)) this.modules[row + r][col + c] = true;
else this.modules[row + r][col + c] = false;
}}
},
getBestMaskPattern: function() {
var minLostPoint = 0, pattern = 0;
for (var i = 0; i < 8; i++) { this.makeImpl(true, i); var lostPoint = QRUtil.getLostPoint(this); if (i == 0 || minLostPoint > lostPoint) { minLostPoint = lostPoint; pattern = i; } }
return pattern;
},
setupTimingPattern: function() {
for (var r = 8; r < this.moduleCount - 8; r++) { if (this.modules[r][6] != null) continue; this.modules[r][6] = (r % 2 == 0); }
for (var c = 8; c < this.moduleCount - 8; c++) { if (this.modules[6][c] != null) continue; this.modules[6][c] = (c % 2 == 0); }
},
setupPositionAdjustPattern: function() {
var pos = QRUtil.PATTERN_POSITION_TABLE[this.typeNumber - 1];
for (var i = 0; i < pos.length; i++) for (var j = 0; j < pos.length; j++) {
var row = pos[i], col = pos[j]; if (this.modules[row][col] != null) continue;
for (var r = -2; r <= 2; r++) for (var c = -2; c <= 2; c++)
this.modules[row + r][col + c] = (r == -2 || r == 2 || c == -2 || c == 2 || (r == 0 && c == 0));
}
},
setupTypeNumber: function(test) {
var bits = QRUtil.getBCHTypeNumber(this.typeNumber);
for (var i = 0; i < 18; i++) { var mod = (!test && ((bits >> i) & 1) == 1); this.modules[Math.floor(i / 3)][i % 3 + this.moduleCount - 8 - 3] = mod; }
for (var i = 0; i < 18; i++) { var mod = (!test && ((bits >> i) & 1) == 1); this.modules[i % 3 + this.moduleCount - 8 - 3][Math.floor(i / 3)] = mod; }
},
setupTypeInfo: function(test, maskPattern) {
var data = (this.errorCorrectLevel << 3) | maskPattern, bits = QRUtil.getBCHTypeInfo(data);
for (var i = 0; i < 15; i++) { var mod = (!test && ((bits >> i) & 1) == 1);
if (i < 6) this.modules[i][8] = mod; else if (i < 8) this.modules[i + 1][8] = mod; else this.modules[this.moduleCount - 15 + i][8] = mod;
}
for (var i = 0; i < 15; i++) { var mod = (!test && ((bits >> i) & 1) == 1);
if (i < 8) this.modules[8][this.moduleCount - i - 1] = mod; else if (i < 9) this.modules[8][15 - i - 1 + 1] = mod; else this.modules[8][15 - i - 1] = mod;
}
this.modules[this.moduleCount - 8][8] = (!test);
},
mapData: function(data, maskPattern) {
var inc = -1, row = this.moduleCount - 1, bitIndex = 7, byteIndex = 0;
for (var col = this.moduleCount - 1; col > 0; col -= 2) { if (col == 6) col--;
while (true) { for (var c = 0; c < 2; c++) {
if (this.modules[row][col - c] == null) {
var dark = false; if (byteIndex < data.length) dark = (((data[byteIndex] >>> bitIndex) & 1) == 1);
if (QRUtil.getMask(maskPattern, row, col - c)) dark = !dark;
this.modules[row][col - c] = dark; bitIndex--;
if (bitIndex == -1) { byteIndex++; bitIndex = 7; }
}}
row += inc; if (row < 0 || this.moduleCount <= row) { row -= inc; inc = -inc; break; }
}}
}
};
qrcode.PAD0 = 0xEC; qrcode.PAD1 = 0x11;
qrcode.createData = function(typeNumber, errorCorrectLevel, dataList) {
var rsBlocks = QRRSBlock.getRSBlocks(typeNumber, errorCorrectLevel), buffer = new QRBitBuffer();
for (var i = 0; i < dataList.length; i++) { var data = dataList[i]; buffer.put(data.getMode(), 4); buffer.put(data.getLength(), QRUtil.getLengthInBits(data.getMode(), typeNumber, data.data)); data.write(buffer); }
var totalDataCount = 0; for (var i = 0; i < rsBlocks.length; i++) totalDataCount += rsBlocks[i].dataCount;
if (buffer.getLengthInBits() > totalDataCount * 8) throw new Error("code length overflow. (" + buffer.getLengthInBits() + ">" + totalDataCount * 8 + ")");
if (buffer.getLengthInBits() + 4 <= totalDataCount * 8) buffer.put(0, 4);
while (buffer.getLengthInBits() % 8 != 0) buffer.putBit(false);
while (true) { if (buffer.getLengthInBits() >= totalDataCount * 8) break; buffer.put(qrcode.PAD0, 8); if (buffer.getLengthInBits() >= totalDataCount * 8) break; buffer.put(qrcode.PAD1, 8); }
return qrcode.createBytes(buffer, rsBlocks);
};
qrcode.createBytes = function(buffer, rsBlocks) {
var offset = 0, maxDcCount = 0, maxEcCount = 0, dcdata = new Array(rsBlocks.length), ecdata = new Array(rsBlocks.length);
for (var r = 0; r < rsBlocks.length; r++) {
var dcCount = rsBlocks[r].dataCount, ecCount = rsBlocks[r].totalCount - dcCount;
maxDcCount = Math.max(maxDcCount, dcCount); maxEcCount = Math.max(maxEcCount, ecCount);
dcdata[r] = new Array(dcCount); for (var i = 0; i < dcdata[r].length; i++) dcdata[r][i] = 0xff & buffer.buffer[i + offset];
offset += dcCount; var rsPoly = QRUtil.getErrorCorrectPolynomial(ecCount), rawPoly = new QRPolynomial(dcdata[r], rsPoly.getLength() - 1), modPoly = rawPoly.mod(rsPoly);
ecdata[r] = new Array(rsPoly.getLength() - 1); for (var i = 0; i < ecdata[r].length; i++) { var modIndex = i + modPoly.getLength() - ecdata[r].length; ecdata[r][i] = (modIndex >= 0) ? modPoly.get(modIndex) : 0; }
}
var totalCodeCount = 0; for (var i = 0; i < rsBlocks.length; i++) totalCodeCount += rsBlocks[i].totalCount;
var data = new Array(totalCodeCount), index = 0;
for (var i = 0; i < maxDcCount; i++) for (var r = 0; r < rsBlocks.length; r++) if (i < dcdata[r].length) data[index++] = dcdata[r][i];
for (var i = 0; i < maxEcCount; i++) for (var r = 0; r < rsBlocks.length; r++) if (i < ecdata[r].length) data[index++] = ecdata[r][i];
return data;
};
var QRMode = { MODE_NUMBER: 1 << 0, MODE_ALPHA_NUM: 1 << 1, MODE_8BIT_BYTE: 1 << 2, MODE_KANJI: 1 << 3 };
var QRErrorCorrectLevel = { L: 1, M: 0, Q: 3, H: 2 };
var QRMaskPattern = { PATTERN000: 0, PATTERN001: 1, PATTERN010: 2, PATTERN011: 3, PATTERN100: 4, PATTERN101: 5, PATTERN110: 6, PATTERN111: 7 };
var QRUtil = {
PATTERN_POSITION_TABLE: [ [], [6, 18], [6, 22], [6, 26], [6, 30], [6, 34], [6, 22, 38], [6, 24, 42], [6, 26, 46], [6, 28, 50], [6, 30, 54], [6, 32, 58], [6, 34, 62], [6, 26, 46, 66], [6, 26, 48, 70], [6, 26, 50, 74], [6, 30, 54, 78], [6, 30, 56, 82], [6, 30, 58, 86], [6, 34, 62, 90], [6, 28, 50, 72, 94], [6, 26, 50, 74, 98], [6, 30, 54, 78, 102], [6, 28, 54, 80, 106], [6, 32, 58, 84, 110], [6, 30, 58, 86, 114], [6, 34, 62, 90, 118], [6, 26, 50, 74, 98, 122], [6, 30, 54, 78, 102, 126], [6, 26, 52, 78, 104, 130], [6, 30, 56, 82, 108, 134], [6, 34, 60, 86, 112, 138], [6, 30, 58, 86, 114, 142], [6, 34, 62, 90, 118, 146], [6, 30, 54, 78, 102, 126, 150], [6, 24, 50, 76, 102, 128, 154], [6, 28, 54, 80, 106, 132, 158], [6, 32, 58, 84, 110, 136, 162], [6, 26, 54, 82, 110, 138, 166], [6, 30, 58, 86, 114, 142, 170] ],
G15: (1 << 10) | (1 << 8) | (1 << 5) | (1 << 4) | (1 << 2) | (1 << 1) | (1 << 0), G18: (1 << 12) | (1 << 11) | (1 << 10) | (1 << 9) | (1 << 8) | (1 << 5) | (1 << 2) | (1 << 0), G15_MASK: (1 << 14) | (1 << 12) | (1 << 10) | (1 << 4) | (1 << 1),
getBCHTypeInfo: function(data) { var d = data << 10; while (QRUtil.getBCHDigit(d) - QRUtil.getBCHDigit(QRUtil.G15) >= 0) d ^= (QRUtil.G15 << (QRUtil.getBCHDigit(d) - QRUtil.getBCHDigit(QRUtil.G15))); return ((data << 10) | d) ^ QRUtil.G15_MASK; },
getBCHTypeNumber: function(data) { var d = data << 12; while (QRUtil.getBCHDigit(d) - QRUtil.getBCHDigit(QRUtil.G18) >= 0) d ^= (QRUtil.G18 << (QRUtil.getBCHDigit(d) - QRUtil.getBCHDigit(QRUtil.G18))); return (data << 12) | d; },
getBCHDigit: function(data) { var digit = 0; while (data != 0) { digit++; data >>>= 1; } return digit; },
getPatternPosition: function(typeNumber) { return QRUtil.PATTERN_POSITION_TABLE[typeNumber - 1]; },
getMask: function(maskPattern, i, j) {
switch (maskPattern) {
case QRMaskPattern.PATTERN000: return (i + j) % 2 == 0; case QRMaskPattern.PATTERN001: return i % 2 == 0; case QRMaskPattern.PATTERN010: return j % 3 == 0; case QRMaskPattern.PATTERN011: return (i + j) % 3 == 0;
case QRMaskPattern.PATTERN100: return (Math.floor(i / 2) + Math.floor(j / 3)) % 2 == 0; case QRMaskPattern.PATTERN101: return (i * j) % 2 + (i * j) % 3 == 0;
case QRMaskPattern.PATTERN110: return ((i * j) % 2 + (i * j) % 3) % 2 == 0; case QRMaskPattern.PATTERN111: return ((i * j) % 3 + (i + j) % 2) % 2 == 0; default: throw new Error("bad maskPattern:" + maskPattern);
}},
getErrorCorrectPolynomial: function(errorCorrectLength) { var a = new QRPolynomial([1], 0); for (var i = 0; i < errorCorrectLength; i++) a = a.multiply(new QRPolynomial([1, QRMath.gexp(i)], 0)); return a; },
getLengthInBits: function(mode, type, textData) {
// Simplified calculation (original is more complex with tables)
if (type < 1 || type > 40) throw new Error("bad type:" + type);
let len = textData ? new QR8bitByte(textData).getLength() : 20; // Approx length for estimation
if (mode === QRMode.MODE_NUMBER) {
if (type >= 1 && type <= 9) return 10;
if (type >= 10 && type <= 26) return 12;
if (type >= 27 && type <= 40) return 14;
} else if (mode === QRMode.MODE_ALPHA_NUM) {
if (type >= 1 && type <= 9) return 9;
if (type >= 10 && type <= 26) return 11;
if (type >= 27 && type <= 40) return 13;
} else if (mode === QRMode.MODE_8BIT_BYTE) {
if (type >= 1 && type <= 9) return 8;
if (type >= 10 && type <= 26) return 16; // This is often 16 for 8bit
if (type >= 27 && type <= 40) return 16;
} else if (mode === QRMode.MODE_KANJI) {
if (type >= 1 && type <= 9) return 8;
if (type >= 10 && type <= 26) return 10;
if (type >= 27 && type <= 40) return 12;
} else { throw new Error("mode:" + mode); }
return 0; // Fallback
},
getLostPoint: function(qrcode) {
var moduleCount = qrcode.getModuleCount(), lostPoint = 0;
for (var row = 0; row < moduleCount; row++) for (var col = 0; col < moduleCount; col++) { var sameCount = 0, dark = qrcode.isDark(row, col); for (var r = -1; r <= 1; r++) { if (row + r < 0 || moduleCount <= row + r) continue; for (var c = -1; c <= 1; c++) { if (col + c < 0 || moduleCount <= col + c) continue; if (r == 0 && c == 0) continue; if (dark == qrcode.isDark(row + r, col + c)) sameCount++; } } if (sameCount > 5) lostPoint += (3 + sameCount - 5); }
for (var row = 0; row < moduleCount - 1; row++) for (var col = 0; col < moduleCount - 1; col++) { var count = 0; if (qrcode.isDark(row, col)) count++; if (qrcode.isDark(row + 1, col)) count++; if (qrcode.isDark(row, col + 1)) count++; if (qrcode.isDark(row + 1, col + 1)) count++; if (count == 0 || count == 4) lostPoint += 3; }
for (var row = 0; row < moduleCount; row++) for (var col = 0; col < moduleCount - 6; col++) if (qrcode.isDark(row, col) && !qrcode.isDark(row, col + 1) && qrcode.isDark(row, col + 2) && qrcode.isDark(row, col + 3) && qrcode.isDark(row, col + 4) && !qrcode.isDark(row, col + 5) && qrcode.isDark(row, col + 6)) lostPoint += 40;
for (var col = 0; col < moduleCount; col++) for (var row = 0; row < moduleCount - 6; row++) if (qrcode.isDark(row, col) && !qrcode.isDark(row + 1, col) && qrcode.isDark(row + 2, col) && qrcode.isDark(row + 3, col) && qrcode.isDark(row + 4, col) && !qrcode.isDark(row + 5, col) && qrcode.isDark(row + 6, col)) lostPoint += 40;
var darkCount = 0; for (var col = 0; col < moduleCount; col++) for (var row = 0; row < moduleCount; row++) if (qrcode.isDark(row, col)) darkCount++;
var ratio = Math.abs(100 * darkCount / moduleCount / moduleCount - 50) / 5; lostPoint += ratio * 10;
return lostPoint;
}
};
var QRMath = { glog: function(n) { if (n < 1) throw new Error("glog(" + n + ")"); return QRMath.LOG_TABLE[n]; }, gexp: function(n) { while (n < 0) n += 255; while (n >= 256) n -= 255; return QRMath.EXP_TABLE[n]; }, EXP_TABLE: new Array(256), LOG_TABLE: new Array(256) };
for (var i = 0; i < 8; i++) QRMath.EXP_TABLE[i] = 1 << i;
for (var i = 8; i < 256; i++) QRMath.EXP_TABLE[i] = QRMath.EXP_TABLE[i - 4] ^ QRMath.EXP_TABLE[i - 5] ^ QRMath.EXP_TABLE[i - 6] ^ QRMath.EXP_TABLE[i - 8];
for (var i = 0; i < 255; i++) QRMath.LOG_TABLE[QRMath.EXP_TABLE[i]] = i;
function QRPolynomial(num, shift) {
if (num.length == undefined) throw new Error(num.length + "/" + shift);
var offset = 0; while (offset < num.length && num[offset] == 0) offset++;
this.num = new Array(num.length - offset + shift); for (var i = 0; i < num.length - offset; i++) this.num[i] = num[i + offset];
}
QRPolynomial.prototype = { get: function(index) { return this.num[index]; }, getLength: function() { return this.num.length; },
multiply: function(e) { var num = new Array(this.getLength() + e.getLength() - 1); for (var i = 0; i < this.getLength(); i++) for (var j = 0; j < e.getLength(); j++) num[i + j] ^= QRMath.gexp(QRMath.glog(this.get(i)) + QRMath.glog(e.get(j))); return new QRPolynomial(num, 0); },
mod: function(e) { if (this.getLength() - e.getLength() < 0) return this; var ratio = QRMath.glog(this.get(0)) - QRMath.glog(e.get(0)); var num = new Array(this.getLength()); for (var i = 0; i < this.getLength(); i++) num[i] = this.get(i); for (var i = 0; i < e.getLength(); i++) num[i] ^= QRMath.gexp(QRMath.glog(e.get(i)) + ratio); return new QRPolynomial(num, 0).mod(e); }
};
function QRRSBlock(totalCount, dataCount) { this.totalCount = totalCount; this.dataCount = dataCount; }
QRRSBlock.RS_BLOCK_TABLE = [ // [totalCount, dataCount]
[1, 26, 19], [1, 26, 16], [1, 26, 13], [1, 26, 9],
[1, 44, 34], [1, 44, 28], [1, 44, 22], [1, 44, 16],
[1, 70, 55], [1, 70, 44], [2, 35, 17], [2, 35, 13],
// ... Many more entries from the original library needed for full range of types/EC levels.
// This is a truncated list for brevity.
// For type 4 (default), EC M: [1, 100, 40] -> need [2, 50, 20] if two blocks
// Type 4, M (ErrorCorrectLevel.M = 0) means index 1
// The table in full QR spec is (version, EC level) -> (num_blocks_group1, codewords_group1, data_codewords_group1, num_blocks_group2, ...)
// The original qrcode.js table is simpler: [[totalCodewords, dataCodewordsPerBlock_EC_L, M, Q, H], [total, data_L, data_M, data_Q, data_H for type 2]...]
// Let's use a simplified version of getRSBlocks that works for common cases.
];
// This getRSBlocks is simplified. A full implementation uses a large table.
QRRSBlock.getRSBlocks = function(typeNumber, errorCorrectLevel) {
var rsBlock = [];
// Example values for type 4 (common default), ErrorCorrectLevel.M
if (typeNumber === 4 && errorCorrectLevel === QRErrorCorrectLevel.M) {
rsBlock.push(new QRRSBlock(20, 16)); // Data count for one block
rsBlock.push(new QRRSBlock(20, 16)); // This would be derived from a table: total 40 data words, 2 blocks.
} else if (typeNumber === 1 && errorCorrectLevel === QRErrorCorrectLevel.L) {
rsBlock.push(new QRRSBlock(26, 19));
} else if (typeNumber === 1 && errorCorrectLevel === QRErrorCorrectLevel.M) {
rsBlock.push(new QRRSBlock(26, 16));
} else if (typeNumber === 10 && errorCorrectLevel === QRErrorCorrectLevel.M) { // for longer text
rsBlock.push(new QRRSBlock(32,24)); rsBlock.push(new QRRSBlock(32,24));
rsBlock.push(new QRRSBlock(32,24)); rsBlock.push(new QRRSBlock(32,24));
}
else { // Fallback for other types/levels, may not be accurate
var dataCount = Math.floor((typeNumber * 4 + 17) * (typeNumber * 4 + 17) * 0.7 / 8) - 20; // very rough estimate
dataCount = Math.max(10, dataCount * (errorCorrectLevel === QRErrorCorrectLevel.L ? 0.75 : (errorCorrectLevel === QRErrorCorrectLevel.M ? 0.6 : 0.4)));
var totalCount = dataCount + Math.floor(dataCount * 0.3); // rough
rsBlock.push(new QRRSBlock(Math.max(20,totalCount), Math.max(15, Math.floor(dataCount))));
}
return rsBlock;
};
function QRBitBuffer() { this.buffer = []; this.length = 0; }
QRBitBuffer.prototype = {
get: function(index) { var bufIndex = Math.floor(index / 8); return ((this.buffer[bufIndex] >>> (7 - index % 8)) & 1) == 1; },
put: function(num, length) { for (var i = 0; i < length; i++) this.putBit(((num >>> (length - i - 1)) & 1) == 1); },
getLengthInBits: function() { return this.length; },
putBit: function(bit) { var bufIndex = Math.floor(this.length / 8); if (this.buffer.length <= bufIndex) this.buffer.push(0); if (bit) this.buffer[bufIndex] |= (0x80 >>> (this.length % 8)); this.length++; }
};
function QR8bitByte(data) {
this.mode = QRMode.MODE_8BIT_BYTE; this.data = data; this.parsedData = [];
for (var i = 0, l = this.data.length; i < l; i++) {
var code = this.data.charCodeAt(i);
if (code > 0x00ff) { // Basic UTF-8 handling (single byte chars only for this simplified ver)
this.parsedData.push(0x3F); // Replace with '?' if multi-byte
} else {
this.parsedData.push(code);
}
}
}
QR8bitByte.prototype = { getLength: function() { return this.parsedData.length; }, write: function(buffer) { for (var i = 0; i < this.parsedData.length; i++) buffer.put(this.parsedData[i], 8); }, getMode: function() { return this.mode; } };
return qrcode;
}();
function createQRCodeGeneratorUI() {
return `
<div class="tool-ui">
<label for="qrText">Text or URL:</label>
<textarea id="qrText" placeholder="Enter text to encode"></textarea>
<label for="qrTypeNumber">Complexity (1-10, affects size/capacity):</label>
<input type="number" id="qrTypeNumber" value="4" min="1" max="10">
<button id="generateQRBtn">Generate QR Code</button>
<canvas id="qrCodeCanvas"></canvas>
<button id="downloadQRBtn" style="margin-top:10px; display:none;">Download QR Code (PNG)</button>
</div>
`;
}
function initQRCodeGenerator() {
const textInput = document.getElementById('qrText');
const typeNumberInput = document.getElementById('qrTypeNumber');
const generateBtn = document.getElementById('generateQRBtn');
const canvas = document.getElementById('qrCodeCanvas');
const downloadBtn = document.getElementById('downloadQRBtn');
generateBtn.addEventListener('click', () => {
const text = textInput.value;
let typeNumber = parseInt(typeNumberInput.value) || 4;
typeNumber = Math.max(1, Math.min(typeNumber, 10)); // Clamp between 1 and 10 for this simplified version
if (!text) {
showOutput('Please enter text or URL.', 'error');
return;
}
showProcessing();
clearOutput(); // Clear previous messages
// Simple UTF-8 check for qrcode.js basic version
let validText = true;
for(let i=0; i < text.length; i++) {
if(text.charCodeAt(i) > 255) {
validText = false;
break;
}
}
if (!validText) {
showOutput('Warning: Input contains multi-byte characters. This basic QR generator handles single-byte (ASCII/Latin-1) characters best. Complex characters might not render correctly.', 'error');
// The embedded QR8bitByte was simplified, this is a heads up.
}
try {
const qr = new qrcode(typeNumber, QRErrorCorrectLevel.M); // M for medium error correction
qr.addData(text);
qr.make();
const moduleCount = qr.getModuleCount();
const cellSize = Math.max(1, Math.floor(250 / moduleCount));
canvas.width = canvas.height = moduleCount * cellSize;
const ctx = canvas.getContext('2d');
ctx.fillStyle = '#FFFFFF';
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = '#000000';
for (let row = 0; row < moduleCount; row++) {
for (let col = 0; col < moduleCount; col++) {
if (qr.isDark(row, col)) {
ctx.fillRect(col * cellSize, row * cellSize, cellSize, cellSize);
}
}
}
downloadBtn.style.display = 'block';
if (!modalToolOutput.classList.contains('error')) { // Don't overwrite error with success
showOutput('QR Code generated.', 'success');
}
} catch (e) {
showOutput(`Error generating QR Code: ${e.message}. Try simpler text, or a higher complexity number if text is long. Max length for type ${typeNumber} might be exceeded.`, 'error');
console.error("QR generation error:", e);
downloadBtn.style.display = 'none';
// Clear canvas on error
const ctx = canvas.getContext('2d');
ctx.clearRect(0,0,canvas.width,canvas.height);
canvas.width=0; canvas.height=0; // Hide it
} finally {
hideProcessing();
}
});
downloadBtn.addEventListener('click', () => {
if (canvas.width > 0 && canvas.height > 0) { // Ensure canvas has content
try {
const dataUrl = canvas.toDataURL('image/png');
const a = document.createElement('a');
a.href = dataUrl;
a.download = 'qrcode.png';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
showOutput('QR Code download initiated.', 'success');
} catch (e) {
showOutput('Failed to prepare QR code for download. Canvas might be tainted if an external image was used (not applicable here).', 'error');
}
} else {
showOutput('No QR code generated to download.', 'error');
}
});
}
// 11. Password Generator
function createPasswordGeneratorUI() {
return `
<div class="tool-ui">
<label for="passLength">Password Length:</label>
<input type="number" id="passLength" value="16" min="4" max="128"> <!-- Min 4 to allow all types -->
<div class="options-group" style="margin: 10px 0;">
<label><input type="checkbox" id="passUpper" checked> Uppercase (A-Z)</label>
<label><input type="checkbox" id="passLower" checked> Lowercase (a-z)</label>
<label><input type="checkbox" id="passNumbers" checked> Numbers (0-9)</label>
<label><input type="checkbox" id="passSymbols" checked> Symbols (!@#$...)</label>
</div>
<button id="generatePassBtn">Generate Password</button>
<div style="display: flex; align-items: center; margin-top:10px;">
<input type="text" id="passwordOutput" readonly title="Generated Password" style="flex-grow:1; background-color: var(--bg-color); border-right:0; border-top-right-radius:0; border-bottom-right-radius:0;">
<button id="copyPassBtn" title="Copy to Clipboard" style="padding: 0.75rem; border-top-left-radius:0; border-bottom-left-radius:0;">??</button>
</div>
</div>
`;
}
function initPasswordGenerator() {
const lengthInput = document.getElementById('passLength');
const upperCheck = document.getElementById('passUpper');
const lowerCheck = document.getElementById('passLower');
const numbersCheck = document.getElementById('passNumbers');
const symbolsCheck = document.getElementById('passSymbols');
const generateBtn = document.getElementById('generatePassBtn');
const outputInput = document.getElementById('passwordOutput'); // Changed to input for easier selection/copy
const copyBtn = document.getElementById('copyPassBtn');
const charSets = {
upper: 'ABCDEFGHIJKLMNOPQRSTUVWXYZ',
lower: 'abcdefghijklmnopqrstuvwxyz',
numbers: '0123456789',
symbols: '!@#$%^&*()_+-=[]{};\':",./<>?'
};
generateBtn.addEventListener('click', () => {
outputInput.value = ''; clearOutput();
const length = parseInt(lengthInput.value);
let charset = '';
let password = '';
let guaranteedChars = ''; // To ensure at least one of each selected type
if (upperCheck.checked) { charset += charSets.upper; guaranteedChars += getRandomChar(charSets.upper); }
if (lowerCheck.checked) { charset += charSets.lower; guaranteedChars += getRandomChar(charSets.lower); }
if (numbersCheck.checked) { charset += charSets.numbers; guaranteedChars += getRandomChar(charSets.numbers); }
if (symbolsCheck.checked) { charset += charSets.symbols; guaranteedChars += getRandomChar(charSets.symbols); }
if (charset === '') {
outputInput.value = 'Select char type(s)';
showOutput('Please select at least one character type.', 'error');
return;
}
if (length < guaranteedChars.length) {
outputInput.value = `Min length ${guaranteedChars.length}`;
showOutput(`Length too short for selected types. Minimum required is ${guaranteedChars.length}.`, 'error');
return;
}
if (length > 128) { // Safety, though input max is 128
outputInput.value = 'Length too long';
showOutput('Password length is too long (max 128).', 'error');
return;
}
// Fill remaining length
for (let i = guaranteedChars.length; i < length; i++) {
password += getRandomChar(charset);
}
password = shuffleString(password + guaranteedChars);
outputInput.value = password;
showOutput('Password generated. Click ?? to copy.', 'success');
});
copyBtn.addEventListener('click', () => {
if (outputInput.value && navigator.clipboard) {
navigator.clipboard.writeText(outputInput.value)
.then(() => showOutput('Password copied to clipboard!', 'success'))
.catch(err => {
showOutput('Failed to copy password. Manual copy may be needed.', 'error');
// Fallback for older browsers or if clipboard fails
outputInput.select(); // Select the text
outputInput.setSelectionRange(0, 99999); // For mobile devices
});
} else if (outputInput.value) { // Fallback for no navigator.clipboard
try {
outputInput.select();
outputInput.setSelectionRange(0, 99999); // For mobile
document.execCommand('copy');
showOutput('Password copied (fallback method).', 'success');
} catch (err) {
showOutput('Copying failed. Please copy manually.', 'error');
}
}
});
function getRandomChar(str) {
return str[Math.floor(Math.random() * str.length)];
}
function shuffleString(str) {
const arr = str.split('');
for (let i = arr.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[arr[i], arr[j]] = [arr[j], arr[i]];
}
return arr.join('');
}
}
// 12. Word Counter
function createWordCounterUI() {
return `
<div class="tool-ui">
<label for="wordCountText">Enter Text:</label>
<textarea id="wordCountText" rows="8" placeholder="Paste or type your text here..."></textarea>
<div id="wordCountResult" class="output-area" style="margin-top:10px; padding: 10px; line-height:1.8;">
Words: 0 <br> Characters (with spaces): 0 <br> Characters (no spaces): 0 <br> Spaces: 0 <br> Reading Time: ~0 min
</div>
</div>
`;
}
function initWordCounter() {
const textArea = document.getElementById('wordCountText');
const resultDiv = document.getElementById('wordCountResult');
const AVG_WPM = 200;
textArea.addEventListener('input', () => {
const text = textArea.value;
const charactersWithSpaces = text.length;
// Words: split by whitespace, filter out empty strings that can result from multiple spaces.
const words = text.trim() === '' ? 0 : text.trim().split(/\s+/).filter(Boolean).length;
const spaces = (text.match(/\s/g) || []).length;
const charsNoSpaces = charactersWithSpaces - spaces;
const readingTime = words > 0 ? Math.ceil(words / AVG_WPM) : 0;
resultDiv.innerHTML = `
Words: <strong>${words}</strong> <br>
Characters (with spaces): <strong>${charactersWithSpaces}</strong> <br>
Characters (no spaces): <strong>${charsNoSpaces}</strong> <br>
Spaces: <strong>${spaces}</strong> <br>
Reading Time: <strong>~${readingTime} min</strong>
`;
});
}
// 13. Base64 Encoder/Decoder
function createBase64EncoderDecoderUI() {
return `
<div class="tool-ui">
<label for="base64Input">Input Text:</label>
<textarea id="base64Input" rows="5" placeholder="Text for encoding or Base64 for decoding"></textarea>
<select id="base64Operation" style="margin-top:10px; margin-bottom:10px;">
<option value="encode">Encode to Base64</option>
<option value="decode">Decode from Base64</option>
</select>
<button id="base64ProcessBtn">Process</button>
<label for="base64Output" style="margin-top:10px;">Output:</label>
<textarea id="base64Output" rows="5" readonly placeholder="Result will appear here"></textarea>
</div>
`;
}
function initBase64EncoderDecoder() {
const inputArea = document.getElementById('base64Input');
const outputArea = document.getElementById('base64Output');
const operationSelect = document.getElementById('base64Operation');
const processBtn = document.getElementById('base64ProcessBtn');
processBtn.addEventListener('click', () => {
const inputText = inputArea.value;
const operation = operationSelect.value;
outputArea.value = '';
clearOutput();
if (!inputText.trim()) {
showOutput('Input is empty.', 'info');
return;
}
try {
if (operation === 'encode') {
// Handle UTF-8 characters correctly using TextEncoder before btoa
const utf8Bytes = new TextEncoder().encode(inputText);
let binaryString = '';
utf8Bytes.forEach(byte => binaryString += String.fromCharCode(byte));
outputArea.value = btoa(binaryString);
showOutput('Encoded successfully.', 'success');
} else { // decode
const binaryString = atob(inputText);
// Convert binary string to Uint8Array for TextDecoder
const bytes = new Uint8Array(binaryString.length);
for (let i = 0; i < binaryString.length; i++) {
bytes[i] = binaryString.charCodeAt(i);
}
outputArea.value = new TextDecoder().decode(bytes); // Decodes as UTF-8
showOutput('Decoded successfully.', 'success');
}
} catch (e) {
let errorMessage = e.message;
if (e.name === "InvalidCharacterError" && operation === 'decode') {
errorMessage = "Invalid Base64 string. Contains characters not part of Base64 alphabet or incorrect padding.";
}
outputArea.value = 'Error: ' + errorMessage;
showOutput('Error during Base64 operation: ' + errorMessage, 'error');
}
});
}
// 14. Color Picker Tool
function createColorPickerUI() {
return `
<div class="tool-ui">
<label for="colorPickerInput">Choose Color:</label>
<div style="display:flex; align-items:center; gap:10px;">
<input type="color" id="colorPickerInput" value="#FFD700" style="width:60px; height:50px; padding:0; border:1px solid var(--input-border); border-radius:5px; cursor:pointer;">
<input type="text" id="hexColorValue" value="#FFD700" style="flex-grow:1;" title="Enter HEX color (e.g., #RRGGBB or #RGB)">
</div>
<div id="colorValues" class="output-area" style="margin-top:10px; line-height:1.8;">
HEX: #FFD700<br>
RGB: rgb(255, 215, 0)<br>
HSL: hsl(51, 100%, 50%)
</div>
</div>
`;
}
function initColorPicker() {
const colorInput = document.getElementById('colorPickerInput');
const hexInput = document.getElementById('hexColorValue');
const valuesDiv = document.getElementById('colorValues');
function updateColorValues(hex) {
// Validate HEX input a bit
if (!/^#([0-9A-F]{3}){1,2}$/i.test(hex)) {
// If invalid, don't update, or show error
// For now, let's just not update if clearly malformed
// Or, try to make it valid if possible (e.g. missing #)
if (hex.length === 6 && /^[0-9A-F]{6}$/i.test(hex)) hex = '#' + hex;
else if (hex.length === 3 && /^[0-9A-F]{3}$/i.test(hex)) hex = '#' + hex;
else return; // Don't proceed with invalid hex
}
const rgb = hexToRgb(hex);
if (!rgb) { // hexToRgb might return null for invalid format
valuesDiv.innerHTML = "Invalid HEX format.";
return;
}
const hsl = rgbToHsl(rgb.r, rgb.g, rgb.b);
valuesDiv.innerHTML = `
HEX: <strong>${hex.toUpperCase()}</strong><br>
RGB: <strong>rgb(${rgb.r}, ${rgb.g}, ${rgb.b})</strong><br>
HSL: <strong>hsl(${hsl.h}, ${hsl.s}%, ${hsl.l}%)</strong>
`;
// Sync both inputs
if(document.activeElement !== colorInput) colorInput.value = hex;
if(document.activeElement !== hexInput) hexInput.value = hex.toUpperCase();
}
colorInput.addEventListener('input', (e) => {
updateColorValues(e.target.value);
});
hexInput.addEventListener('input', (e) => { // Allow manual HEX input
updateColorValues(e.target.value);
});
hexInput.addEventListener('change', (e) => { // On blur or enter, finalize
updateColorValues(e.target.value);
});
// Initial value
updateColorValues(colorInput.value);
function hexToRgb(hex) {
hex = hex.replace(/^#/, '');
if (!/^[0-9A-F]+$/i.test(hex) || (hex.length !== 3 && hex.length !== 6)) {
return null; // Invalid hex string
}
if (hex.length === 3) {
hex = hex.split('').map(char => char + char).join('');
}
const bigint = parseInt(hex, 16);
const r = (bigint >> 16) & 255;
const g = (bigint >> 8) & 255;
const b = bigint & 255;
return { r, g, b };
}
function rgbToHsl(r, g, b) {
r /= 255; g /= 255; b /= 255;
const max = Math.max(r, g, b), min = Math.min(r, g, b);
let h, s, l = (max + min) / 2;
if (max === min) {
h = s = 0;
} else {
const d = max - min;
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
switch (max) {
case r: h = (g - b) / d + (g < b ? 6 : 0); break;
case g: h = (b - r) / d + 2; break;
case b: h = (r - g) / d + 4; break;
}
h /= 6;
}
return {
h: Math.round(h * 360),
s: Math.round(s * 100),
l: Math.round(l * 100)
};
}
}
// 15. Text to Speech
function createTextToSpeechUI() {
return `
<div class="tool-ui">
<label for="ttsText">Text to Speak:</label>
<textarea id="ttsText" rows="5" placeholder="Enter text here..."></textarea>
<label for="ttsVoice">Voice:</label>
<select id="ttsVoice" style="margin-bottom:10px;"></select>
<div style="display:flex; gap:10px; align-items:center; margin-bottom:5px;">
<label for="ttsRate" style="margin-bottom:0; white-space:nowrap;">Rate:</label>
<input type="range" id="ttsRate" min="0.1" max="2" step="0.1" value="1" style="flex-grow:1;">
<span id="ttsRateValue" style="width:25px; text-align:right;">1</span>
</div>
<div style="display:flex; gap:10px; align-items:center; margin-bottom:10px;">
<label for="ttsPitch" style="margin-bottom:0; white-space:nowrap;">Pitch:</label>
<input type="range" id="ttsPitch" min="0" max="2" step="0.1" value="1" style="flex-grow:1;">
<span id="ttsPitchValue" style="width:25px; text-align:right;">1</span>
</div>
<div style="display:flex; gap:10px; margin-top:10px;">
<button id="speakBtn" style="flex:1;">Speak</button>
<button id="pauseBtn" style="flex:1;" disabled>Pause</button>
<button id="resumeBtn" style="flex:1;" disabled>Resume</button>
<button id="stopBtn" style="flex:1;">Stop</button>
</div>
</div>
`;
}
function initTextToSpeech() {
const textInput = document.getElementById('ttsText');
const voiceSelect = document.getElementById('ttsVoice');
const rateInput = document.getElementById('ttsRate');
const rateValue = document.getElementById('ttsRateValue');
const pitchInput = document.getElementById('ttsPitch');
const pitchValue = document.getElementById('ttsPitchValue');
const speakBtn = document.getElementById('speakBtn');
const pauseBtn = document.getElementById('pauseBtn');
const resumeBtn = document.getElementById('resumeBtn');
const stopBtn = document.getElementById('stopBtn');
const synth = window.speechSynthesis;
if (!synth) {
showOutput('Speech Synthesis API not supported by this browser.', 'error');
[speakBtn, pauseBtn, resumeBtn, stopBtn, rateInput, pitchInput, voiceSelect].forEach(el => el.disabled = true);
return;
}
let voices = [];
let currentUtterance = null;
function populateVoiceList() {
voices = synth.getVoices().sort((a,b) => a.name.localeCompare(b.name)); // Sort voices by name
const previouslySelected = voiceSelect.value;
voiceSelect.innerHTML = '';
voices.forEach((voice) => {
const option = document.createElement('option');
option.textContent = `${voice.name} (${voice.lang})`;
option.value = voice.name; // Use voice name as value for easier lookup
if (voice.default) option.selected = true;
voiceSelect.appendChild(option);
});
if (previouslySelected) voiceSelect.value = previouslySelected; // Restore selection if possible
}
populateVoiceList();
if (speechSynthesis.onvoiceschanged !== undefined) {
speechSynthesis.onvoiceschanged = populateVoiceList;
}
rateInput.addEventListener('input', () => rateValue.textContent = parseFloat(rateInput.value).toFixed(1));
pitchInput.addEventListener('input', () => pitchValue.textContent = parseFloat(pitchInput.value).toFixed(1));
function updateButtonStates() {
if (synth.speaking && !synth.paused) { // Speaking
speakBtn.disabled = true; pauseBtn.disabled = false; resumeBtn.disabled = true; stopBtn.disabled = false;
} else if (synth.paused) { // Paused
speakBtn.disabled = true; pauseBtn.disabled = true; resumeBtn.disabled = false; stopBtn.disabled = false;
} else { // Idle or finished
speakBtn.disabled = false; pauseBtn.disabled = true; resumeBtn.disabled = true; stopBtn.disabled = true;
}
}
updateButtonStates(); // Initial state
speakBtn.addEventListener('click', () => {
const text = textInput.value;
if (!text.trim()) {
showOutput('Please enter some text to speak.', 'error');
return;
}
if (synth.speaking) synth.cancel(); // Stop current before starting new
clearOutput();
currentUtterance = new SpeechSynthesisUtterance(text);
const selectedVoice = voices.find(voice => voice.name === voiceSelect.value);
if (selectedVoice) currentUtterance.voice = selectedVoice;
currentUtterance.pitch = parseFloat(pitchInput.value);
currentUtterance.rate = parseFloat(rateInput.value);
currentUtterance.onstart = () => { showOutput('Speaking...', 'info'); updateButtonStates(); };
currentUtterance.onend = () => { showOutput('Finished speaking.', 'success'); updateButtonStates(); currentUtterance = null;};
currentUtterance.onerror = (e) => { showOutput(`Error during speech: ${e.error}`, 'error'); updateButtonStates(); currentUtterance = null;};
currentUtterance.onpause = () => { showOutput('Speech paused.', 'info'); updateButtonStates(); };
currentUtterance.onresume = () => { showOutput('Speech resumed.', 'info'); updateButtonStates(); };
synth.speak(currentUtterance);
});
pauseBtn.addEventListener('click', () => { if (synth.speaking) synth.pause(); updateButtonStates(); });
resumeBtn.addEventListener('click', () => { if (synth.paused) synth.resume(); updateButtonStates(); });
stopBtn.addEventListener('click', () => { if (synth.speaking || synth.paused) synth.cancel(); updateButtonStates(); showOutput('Speech stopped.', 'info'); currentUtterance = null; });
tools.find(t => t.id === 'textToSpeech').cleanup = () => {
if (synth && (synth.speaking || synth.paused)) {
synth.cancel();
}
currentUtterance = null;
};
}
// 16. Speech to Text
function createSpeechToTextUI() {
return `
<div class="tool-ui">
<label for="sttOutput">Recognized Text:</label>
<textarea id="sttOutput" rows="5" placeholder="Recognized text will appear here..."></textarea>
<div style="display:flex; gap:10px; margin-top:10px;">
<button id="startRecognitionBtn" style="flex:1;">Start Listening</button>
<button id="stopRecognitionBtn" style="flex:1;" disabled>Stop Listening</button>
</div>
<p id="sttStatus" style="margin-top:10px; font-style:italic;">Status: Idle</p>
</div>
`;
}
function initSpeechToText() {
const outputArea = document.getElementById('sttOutput');
const startBtn = document.getElementById('startRecognitionBtn');
const stopBtn = document.getElementById('stopRecognitionBtn');
const statusP = document.getElementById('sttStatus');
const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
if (!SpeechRecognition) {
showOutput('Speech Recognition API not supported by this browser.', 'error');
startBtn.disabled = true; stopBtn.disabled = true;
statusP.textContent = 'Status: API Not Supported';
return;
}
let recognition = new SpeechRecognition(); // Create new instance for each session
recognition.continuous = true;
recognition.interimResults = true;
// recognition.lang = 'en-US'; // User's browser default usually works fine
let finalTranscript = '';
function setupRecognition() {
recognition = new SpeechRecognition();
recognition.continuous = true;
recognition.interimResults = true;
// recognition.lang = 'en-US'; // Set lang if specific needed
recognition.onstart = () => {
statusP.textContent = 'Status: Listening...';
startBtn.disabled = true; stopBtn.disabled = false;
clearOutput();
};
recognition.onresult = (event) => {
let interimTranscript = '';
finalTranscript = ''; // Rebuild finalTranscript from all final results
for (let i = 0; i < event.results.length; ++i) {
if (event.results[i].isFinal) {
finalTranscript += event.results[i][0].transcript + ' ';
} else {
interimTranscript += event.results[i][0].transcript;
}
}
outputArea.value = finalTranscript.trim() + (interimTranscript ? ' ' + interimTranscript : '');
};
recognition.onerror = (event) => {
statusP.textContent = `Status: Error - ${event.error}`;
if (event.error === 'no-speech' || event.error === 'audio-capture' || event.error === 'not-allowed' || event.error === 'network') {
stopRecognitionInstance(); // Clean up and reset UI
}
showOutput(`Speech recognition error: ${event.error}. If 'not-allowed', check microphone permissions.`, 'error');
};
recognition.onend = () => {
stopRecognitionInstance(); // Ensure UI updates when recognition ends naturally or by stop()
};
}
function stopRecognitionInstance(){
if (recognition && (recognition.readyState === 1 /* listening */ || recognition.readyState === 2 /* processing */ )) {
// recognition.stop(); // This seems to trigger onend again, so be careful with direct calls
}
statusP.textContent = 'Status: Idle. Press Start to try again.';
startBtn.disabled = false; stopBtn.disabled = true;
if (finalTranscript.trim() && !modalToolOutput.classList.contains('error')) { // Don't overwrite error msg
showOutput('Speech recognition finished.', 'success');
}
}
startBtn.addEventListener('click', () => {
finalTranscript = '';
outputArea.value = '';
setupRecognition(); // Re-init for fresh start, handles permissions better
try {
recognition.start();
} catch (e) {
statusP.textContent = `Status: Error - ${e.message}`;
showOutput(`Could not start recognition: ${e.message}`, 'error');
startBtn.disabled = false; stopBtn.disabled = true;
}
});
stopBtn.addEventListener('click', () => {
if (recognition) recognition.stop(); // This will trigger onend handler
});
tools.find(t => t.id === 'speechToText').cleanup = () => {
if (recognition) {
recognition.abort(); // Force stop without triggering all events if modal closes
}
};
// Initial setup if needed, but startBtn click will set it up
}
// 17. JSON Formatter
function createJSONFormatterUI() {
return `
<div class="tool-ui">
<label for="jsonInput">Paste JSON here:</label>
<textarea id="jsonInput" rows="8" placeholder='{"name": "John Doe", "age": 30, "isStudent": false, "courses": [{"title": "History", "credits": 3}, {"title": "Math", "credits": 4}]}'></textarea>
<button id="formatJsonBtn" style="margin-top:10px;">Format / Validate JSON</button>
<label for="jsonOutput" style="margin-top:10px;">Formatted JSON / Error:</label>
<textarea id="jsonOutput" rows="8" readonly></textarea>
</div>
`;
}
function initJSONFormatter() {
const inputArea = document.getElementById('jsonInput');
const outputArea = document.getElementById('jsonOutput');
const formatBtn = document.getElementById('formatJsonBtn');
formatBtn.addEventListener('click', () => {
const inputText = inputArea.value.trim();
outputArea.value = ''; clearOutput();
if (!inputText) {
showOutput('Input is empty.', 'info');
return;
}
try {
const jsonObj = JSON.parse(inputText);
outputArea.value = JSON.stringify(jsonObj, null, 2); // 2 spaces for indentation
outputArea.style.color = 'var(--text-color)';
showOutput('JSON formatted and validated successfully.', 'success');
} catch (e) {
outputArea.value = `Invalid JSON: ${e.message}`;
outputArea.style.color = 'var(--error-color)';
showOutput(`Invalid JSON: ${e.message}`, 'error');
}
});
// Auto-format on paste (optional)
inputArea.addEventListener('paste', (event) => {
// Use a short timeout to allow the pasted content to actually appear in the textarea
setTimeout(() => {
formatBtn.click(); // Simulate click to format/validate
}, 0);
});
}
// 18. Unit Converter
function createUnitConverterUI() {
return `
<div class="tool-ui">
<label for="unitCategory">Category:</label>
<select id="unitCategory" style="margin-bottom:10px;"></select>
<div style="display:flex; gap:10px; align-items:flex-end;">
<div style="flex:2;">
<label for="unitInputValue">Value:</label>
<input type="number" id="unitInputValue" value="1">
</div>
<div style="flex:3;">
<label for="unitFrom">From:</label>
<select id="unitFrom"></select>
</div>
<div style="flex:0 0 auto; text-align:center; padding-bottom:0.75rem; font-size:1.5em;">→</div>
<div style="flex:3;">
<label for="unitTo">To:</label>
<select id="unitTo"></select>
</div>
</div>
<div id="unitResult" class="output-area" style="margin-top:15px; font-size:1.2em; text-align:center; padding:10px;"></div>
</div>
`;
}
function initUnitConverter() {
const categorySelect = document.getElementById('unitCategory');
const fromSelect = document.getElementById('unitFrom');
const toSelect = document.getElementById('unitTo');
const inputValue = document.getElementById('unitInputValue');
const resultDiv = document.getElementById('unitResult');
const units = {
length: {
meter: { name: 'Meter (m)', factor: 1 }, kilometer: { name: 'Kilometer (km)', factor: 1000 }, centimeter: { name: 'Centimeter (cm)', factor: 0.01 }, millimeter: { name: 'Millimeter (mm)', factor: 0.001 },
mile: { name: 'Mile (mi)', factor: 1609.344 }, yard: { name: 'Yard (yd)', factor: 0.9144 }, foot: { name: 'Foot (ft)', factor: 0.3048 }, inch: { name: 'Inch (in)', factor: 0.0254 },
},
weight: {
kilogram: { name: 'Kilogram (kg)', factor: 1 }, gram: { name: 'Gram (g)', factor: 0.001 }, milligram: { name: 'Milligram (mg)', factor: 0.000001 }, metric_ton: { name: 'Metric Ton (t)', factor: 1000},
pound: { name: 'Pound (lb)', factor: 0.45359237 }, ounce: { name: 'Ounce (oz)', factor: 0.0283495231 },
},
temperature: { // Special handling, factors are not direct multipliers
celsius: { name: 'Celsius (°C)' }, fahrenheit: { name: 'Fahrenheit (°F)' }, kelvin: { name: 'Kelvin (K)' },
},
area: {
sq_meter: { name: 'Square Meter (m²)', factor: 1}, sq_kilometer: { name: 'Square Kilometer (km²)', factor: 1e6}, sq_centimeter: { name: 'Square Centimeter (cm²)', factor: 1e-4},
hectare: { name: 'Hectare (ha)', factor: 1e4}, sq_mile: { name: 'Square Mile (mi²)', factor: 2.59e6}, acre: { name: 'Acre', factor: 4046.86},
},
volume: {
liter: { name: 'Liter (L)', factor: 1}, milliliter: { name: 'Milliliter (mL)', factor: 0.001}, cubic_meter: { name: 'Cubic Meter (m³)', factor: 1000},
gallon_us: { name: 'US Gallon (gal)', factor: 3.78541}, pint_us: {name: 'US Pint (pt)', factor: 0.473176},
}
};
function populateCategories() {
for (const category in units) {
const option = document.createElement('option');
option.value = category;
option.textContent = category.charAt(0).toUpperCase() + category.slice(1).replace('_', ' ');
categorySelect.appendChild(option);
}
}
function populateUnits(category) {
fromSelect.innerHTML = ''; toSelect.innerHTML = '';
const categoryUnits = units[category];
let defaultFromSet = false, defaultToSet = false;
let count = 0;
for (const unitKey in categoryUnits) {
const option = document.createElement('option');
option.value = unitKey;
option.textContent = categoryUnits[unitKey].name;
fromSelect.appendChild(option.cloneNode(true));
toSelect.appendChild(option.cloneNode(true));
count++;
}
// Set default selections (e.g., first for 'from', second for 'to')
if (fromSelect.options.length > 0) fromSelect.selectedIndex = 0;
if (toSelect.options.length > 1) toSelect.selectedIndex = 1;
else if (toSelect.options.length > 0) toSelect.selectedIndex = 0;
}
function convert() {
const category = categorySelect.value;
const fromUnitKey = fromSelect.value;
const toUnitKey = toSelect.value;
const val = parseFloat(inputValue.value);
resultDiv.textContent = ''; clearOutput();
if (isNaN(val)) {
resultDiv.textContent = 'Invalid input value';
return;
}
if (!fromUnitKey || !toUnitKey) { // Should not happen if populated correctly
resultDiv.textContent = 'Select units';
return;
}
let result;
if (category === 'temperature') {
if (fromUnitKey === toUnitKey) result = val;
else if (fromUnitKey === 'celsius') {
if (toUnitKey === 'fahrenheit') result = (val * 9/5) + 32;
else if (toUnitKey === 'kelvin') result = val + 273.15;
} else if (fromUnitKey === 'fahrenheit') {
if (toUnitKey === 'celsius') result = (val - 32) * 5/9;
else if (toUnitKey === 'kelvin') result = (val - 32) * 5/9 + 273.15;
} else if (fromUnitKey === 'kelvin') {
if (toUnitKey === 'celsius') result = val - 273.15;
else if (toUnitKey === 'fahrenheit') result = (val - 273.15) * 9/5 + 32;
}
} else {
const fromUnit = units[category][fromUnitKey];
const toUnit = units[category][toUnitKey];
const valueInBase = val * fromUnit.factor;
result = valueInBase / toUnit.factor;
}
// Use toFixed for reasonable precision, avoid overly long decimals.
// Precision can be dynamic based on number magnitude if needed.
let precision = 5;
if (Math.abs(result) > 1000) precision = 2;
if (Math.abs(result) < 0.001 && result !== 0) precision = 7;
const resultUnitName = units[category][toUnitKey].name.split('(')[0].trim();
resultDiv.textContent = `${val} ${units[category][fromUnitKey].name.split('(')[0].trim()} = ${result.toFixed(precision)} ${resultUnitName}`;
}
categorySelect.addEventListener('change', (e) => {
populateUnits(e.target.value);
convert();
});
fromSelect.addEventListener('change', convert);
toSelect.addEventListener('change', convert);
inputValue.addEventListener('input', convert);
populateCategories();
if (categorySelect.options.length > 0) { // Ensure categories populated
populateUnits(categorySelect.value);
convert();
}
}
// 19. BMI Calculator
function createBMICalculatorUI() {
return `
<div class="tool-ui">
<div style="display:flex; gap:10px;">
<div style="flex:1;">
<label for="bmiWeight">Weight (kg):</label>
<input type="number" id="bmiWeight" placeholder="e.g., 70" min="0">
</div>
<div style="flex:1;">
<label for="bmiHeight">Height (cm):</label>
<input type="number" id="bmiHeight" placeholder="e.g., 175" min="0">
</div>
</div>
<button id="calculateBmiBtn" style="margin-top:10px;">Calculate BMI</button>
<div id="bmiResult" class="output-area" style="margin-top:10px; line-height:1.8;"></div>
</div>
`;
}
function initBMICalculator() {
const weightInput = document.getElementById('bmiWeight');
const heightInput = document.getElementById('bmiHeight');
const calculateBtn = document.getElementById('calculateBmiBtn');
const resultDiv = document.getElementById('bmiResult');
calculateBtn.addEventListener('click', () => {
resultDiv.innerHTML = ''; clearOutput();
const weight = parseFloat(weightInput.value);
const heightCm = parseFloat(heightInput.value);
if (isNaN(weight) || weight <= 0 || isNaN(heightCm) || heightCm <= 0) {
resultDiv.textContent = 'Please enter valid positive weight (kg) and height (cm).';
showOutput('Invalid input for BMI.', 'error');
return;
}
const heightM = heightCm / 100;
const bmi = weight / (heightM * heightM);
let category = '';
let color = 'var(--text-color)'; // Default color
if (bmi < 18.5) { category = 'Underweight'; color = 'lightblue'; }
else if (bmi < 24.9) { category = 'Normal weight'; color = 'var(--success-color)'; }
else if (bmi < 29.9) { category = 'Overweight'; color = 'orange';}
else if (bmi < 34.9) { category = 'Obesity Class I'; color = 'var(--error-color)';}
else if (bmi < 39.9) { category = 'Obesity Class II'; color = 'darkred';}
else { category = 'Obesity Class III (Severe)'; color = 'purple';}
resultDiv.innerHTML = `
Your BMI: <strong style="font-size:1.2em;">${bmi.toFixed(1)}</strong><br>
Category: <strong style="color:${color};">${category}</strong>
`;
showOutput(`BMI calculated: ${bmi.toFixed(1)} (${category})`, 'success');
});
}
// 20. Timer / Stopwatch Tool
function createTimerStopwatchUI() {
return `
<div class="tool-ui">
<div style="text-align:center; margin-bottom:20px; border-bottom: 1px solid var(--card-bg); padding-bottom:20px;">
<h4 style="color:var(--accent-color); margin-bottom:5px;">Stopwatch</h4>
<div id="stopwatchDisplay" style="font-size: 2.2em; margin:10px 0; font-family:monospace;">00:00:00.000</div>
<div style="display:flex; gap:10px; justify-content:center;">
<button id="swStartBtn" style="flex:1;">Start</button>
<button id="swStopBtn" style="flex:1;" disabled>Stop</button>
<button id="swResetBtn" style="flex:1;" disabled>Reset</button>
</div>
</div>
<div style="text-align:center;">
<h4 style="color:var(--accent-color); margin-bottom:5px;">Timer</h4>
<div style="display:flex; gap:5px; justify-content:center; margin-bottom:10px; align-items:center;">
<input type="number" id="timerHours" placeholder="HH" min="0" max="99" value="0" style="width:60px; text-align:center;"> :
<input type="number" id="timerMinutes" placeholder="MM" min="0" max="59" value="1" style="width:60px; text-align:center;"> :
<input type="number" id="timerSeconds" placeholder="SS" min="0" max="59" value="0" style="width:60px; text-align:center;">
</div>
<div id="timerDisplay" style="font-size: 2.2em; margin:10px 0; font-family:monospace;">00:01:00</div>
<div style="display:flex; gap:10px; justify-content:center;">
<button id="timerStartBtn" style="flex:1;">Start</button>
<button id="timerPauseBtn" style="flex:1;" disabled>Pause</button>
<button id="timerResetBtn" style="flex:1;">Reset</button>
</div>
</div>
</div>
`;
}
function initTimerStopwatch() {
// Stopwatch
const swDisplay = document.getElementById('stopwatchDisplay');
const swStartBtn = document.getElementById('swStartBtn');
const swStopBtn = document.getElementById('swStopBtn');
const swResetBtn = document.getElementById('swResetBtn');
let swInterval, swStartTime, swElapsedTime = 0;
function formatSWTime(ms) {
const totalSeconds = Math.floor(ms / 1000);
const hours = String(Math.floor(totalSeconds / 3600)).padStart(2, '0');
const minutes = String(Math.floor((totalSeconds % 3600) / 60)).padStart(2, '0');
const seconds = String(totalSeconds % 60).padStart(2, '0');
const milliseconds = String(ms % 1000).padStart(3, '0');
return `${hours}:${minutes}:${seconds}.${milliseconds}`;
}
swStartBtn.addEventListener('click', () => {
if (swInterval) return;
swStartTime = Date.now() - swElapsedTime;
swInterval = setInterval(() => {
swElapsedTime = Date.now() - swStartTime;
swDisplay.textContent = formatSWTime(swElapsedTime);
}, 10);
swStartBtn.disabled = true; swStopBtn.disabled = false; swResetBtn.disabled = true;
});
swStopBtn.addEventListener('click', () => {
clearInterval(swInterval); swInterval = null;
swStartBtn.disabled = false; swStopBtn.disabled = true; swResetBtn.disabled = false;
});
swResetBtn.addEventListener('click', () => {
clearInterval(swInterval); swInterval = null; swElapsedTime = 0;
swDisplay.textContent = formatSWTime(0);
swStartBtn.disabled = false; swStopBtn.disabled = true; swResetBtn.disabled = true;
});
// Timer
const timerHoursInput = document.getElementById('timerHours');
const timerMinutesInput = document.getElementById('timerMinutes');
const timerSecondsInput = document.getElementById('timerSeconds');
const timerDisplay = document.getElementById('timerDisplay');
const timerStartBtn = document.getElementById('timerStartBtn');
const timerPauseBtn = document.getElementById('timerPauseBtn');
const timerResetBtn = document.getElementById('timerResetBtn');
let timerInterval, timerRemainingSeconds;
let timerAudio; // Declare, will initialize on first finish
function initializeTimerAudio() {
if (!timerAudio) {
timerAudio = new Audio("data:audio/wav;base64,UklGRl9vT19XQVZFZm10IBAAAAABAAEAQB8AAEAfAAABAAgAZGF0YU"+Array(1e3).join("123"));
}
}
function formatTimerDisplay(totalSeconds) {
const h = String(Math.floor(totalSeconds / 3600)).padStart(2, '0');
const m = String(Math.floor((totalSeconds % 3600) / 60)).padStart(2, '0');
const s = String(totalSeconds % 60).padStart(2, '0');
return `${h}:${m}:${s}`;
}
function setTimerInputsDisabled(isDisabled) {
timerHoursInput.disabled = isDisabled;
timerMinutesInput.disabled = isDisabled;
timerSecondsInput.disabled = isDisabled;
}
function updateTimerDisplayFromInputs() {
const h = parseInt(timerHoursInput.value) || 0;
const m = parseInt(timerMinutesInput.value) || 0;
const s = parseInt(timerSecondsInput.value) || 0;
timerRemainingSeconds = (h * 3600) + (m * 60) + s;
timerDisplay.textContent = formatTimerDisplay(timerRemainingSeconds);
}
[timerHoursInput, timerMinutesInput, timerSecondsInput].forEach(input => {
input.addEventListener('change', updateTimerDisplayFromInputs);
input.addEventListener('input', () => { // For live update if user types
// Basic validation to prevent non-numeric or excessive values during typing
input.value = input.value.replace(/[^0-9]/g, '');
if (input.id === 'timerMinutes' || input.id === 'timerSeconds') {
if (parseInt(input.value) > 59) input.value = '59';
}
if (input.id === 'timerHours') {
if (parseInt(input.value) > 99) input.value = '99';
}
if (input.value === '') input.value = '0'; // Default to 0 if cleared
});
});
timerStartBtn.addEventListener('click', () => {
if (timerInterval) return;
updateTimerDisplayFromInputs(); // Get current values from inputs
if (timerRemainingSeconds <= 0) {
showOutput('Please set a timer duration greater than 0.', 'error');
return;
}
clearOutput();
setTimerInputsDisabled(true);
timerStartBtn.disabled = true; timerPauseBtn.disabled = false; timerResetBtn.disabled = false;
timerDisplay.style.color = 'var(--text-color)';
timerInterval = setInterval(() => {
timerRemainingSeconds--;
timerDisplay.textContent = formatTimerDisplay(timerRemainingSeconds);
if (timerRemainingSeconds <= 0) {
clearInterval(timerInterval); timerInterval = null;
timerDisplay.textContent = '00:00:00';
timerDisplay.style.color = 'var(--accent-color)';
showOutput('Timer finished!', 'success');
initializeTimerAudio();
try { timerAudio.play(); } catch(e) { /* ignore if blocked */ }
timerStartBtn.disabled = false; // Allow restart with same values or new
timerPauseBtn.disabled = true;
setTimerInputsDisabled(false); // Re-enable inputs
}
}, 1000);
});
timerPauseBtn.addEventListener('click', () => {
clearInterval(timerInterval); timerInterval = null;
timerStartBtn.disabled = false; timerPauseBtn.disabled = true;
// Keep inputs disabled while paused to prevent confusion
});
timerResetBtn.addEventListener('click', () => {
clearInterval(timerInterval); timerInterval = null;
timerHoursInput.value = '0'; timerMinutesInput.value = '1'; timerSecondsInput.value = '0'; // Reset inputs
updateTimerDisplayFromInputs(); // Update display based on reset inputs
timerDisplay.style.color = 'var(--text-color)';
setTimerInputsDisabled(false);
timerStartBtn.disabled = false; timerPauseBtn.disabled = true; timerResetBtn.disabled = true; // Reset button itself often becomes disabled
clearOutput();
});
// Initialize timer display & button states
updateTimerDisplayFromInputs();
timerResetBtn.disabled = true; // Initially, reset is not needed until started/paused
tools.find(t => t.id === 'timerStopwatch').cleanup = () => {
clearInterval(swInterval);
clearInterval(timerInterval);
};
}
}); // End DOMContentLoaded
</script>
</body>
</html>