1class EnhancedVoiceTyping { 2 constructor(textareaId, checkboxId, options = {}) { 3 // Element references 4 this.textarea = document.getElementById(textareaId); 5 this.checkbox = document.getElementById(checkboxId); 6 7 // Configuration with defaults 8 this.config = { 9 lang: options.lang || 'en-US', 10 continuous: options.continuous !== false, 11 interimResults: options.interimResults !== false, 12 maxAlternatives: options.maxAlternatives || 3, 13 autoRestart: options.autoRestart !== false, 14 autoCapitalize: options.autoCapitalize !== false, 15 autoPunctuation: options.autoPunctuation !== false, 16 commands: options.commands !== false, 17 visualFeedback: options.visualFeedback !== false, 18 autoSubmit: options.autoSubmit !== false, 19 confirmSubmit: options.confirmSubmit || false, 20 removeCommandWords: options.removeCommandWords !== false, 21 ...options 22 }; 23 24 // State management 25 this.state = { 26 isListening: false, 27 isPaused: false, 28 finalText: '', 29 interimText: '', 30 lastSpeechTime: Date.now(), 31 silenceTimer: null, 32 errorCount: 0, 33 history: [], 34 cursorPosition: 0 35 }; 36 37 // Submission flag to prevent duplicates 38 this.isSubmitting = false; 39 40 // Speech recognition setup 41 this.SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition; 42 this.recognition = null; 43 44 // Voice commands 45 this.voiceCommands = { 46 'new line': () => this.insertText('\n'), 47 'new paragraph': () => this.insertText('\n\t'), 48 'comma': () => this.insertText(', '), 49 'period': () => this.insertText('. '), 50 'question mark': () => this.insertText('? '), 51 'exclamation': () => this.insertText('! '), 52 'clear all': () => this.clearText(), 53 'undo': () => this.undo(), 54 'stop listening': () => this.stop(), 55 'pause': () => this.pause(), 56 'resume': () => this.resume(), 57 'enter': () => this.submitForm(), 58 'submit': () => this.submitForm(), 59 'send': () => this.submitForm(), 60 'submit form': () => this.submitForm(), 61 'send message': () => this.submitForm() 62 }; 63 64 this.init(); 65 } 66 67 init() { 68 if (!this.SpeechRecognition) { 69 this.showError('Speech Recognition is not supported in your browser. Please use Chrome, Edge, or Safari.'); 70 this.checkbox.disabled = true; 71 return; 72 } 73 74 this.setupRecognition(); 75 this.setupEventListeners(); 76 this.setupVisualFeedback(); 77 this.loadSavedState(); 78 } 79 80 setupRecognition() { 81 this.recognition = new this.SpeechRecognition(); 82 this.recognition.continuous = this.config.continuous; 83 this.recognition.interimResults = this.config.interimResults; 84 this.recognition.lang = this.config.lang; 85 this.recognition.maxAlternatives = this.config.maxAlternatives; 86 87 // Event handlers 88 this.recognition.onstart = () => this.handleStart(); 89 this.recognition.onresult = (event) => this.handleResult(event); 90 this.recognition.onerror = (event) => this.handleError(event); 91 this.recognition.onend = () => this.handleEnd(); 92 this.recognition.onspeechend = () => this.handleSpeechEnd();
93 this.recognition.onnomatch = () => this.handleNoMatch(); 94 } 95 96 setupEventListeners() { 97 // Checkbox toggle 98 this.checkbox.addEventListener('change', () => { 99 if (this.checkbox.checked) { 100 this.start(); 101 } else { 102 this.stop(); 103 } 104 }); 105 106 // Keyboard shortcuts 107 document.addEventListener('keydown', (e) => { 108 // Alt + V to toggle voice typing 109 if (e.altKey && e.key === 'v') { 110 e.preventDefault(); 111 this.checkbox.checked = !this.checkbox.checked; 112 this.checkbox.dispatchEvent(new Event('change')); 113 } 114 115 // Escape to stop 116 if (e.key === 'Escape' && this.state.isListening) { 117 this.stop(); 118 } 119 }); 120 121 // Auto-save on input 122 this.textarea.addEventListener('input', () => { 123 this.saveState(); 124 }); 125 126 // Track cursor position 127 this.textarea.addEventListener('click', () => { 128 this.state.cursorPosition = this.textarea.selectionStart; 129 }); 130 131 this.textarea.addEventListener('keyup', () => { 132 this.state.cursorPosition = this.textarea.selectionStart; 133 }); 134 135 // Handle page visibility changes 136 document.addEventListener('visibilitychange', () => { 137 if (document.hidden && this.state.isListening) { 138 this.pause(); 139 } 140 }); 141 142 // Handle before unload 143 window.addEventListener('beforeunload', () => { 144 if (this.state.isListening) { 145 this.forceStop(); 146 } 147 }); 148 } 149 150 setupVisualFeedback() { 151 if (!this.config.visualFeedback) return; 152 153 // Create visual indicator 154 const indicator = document.createElement('div'); 155 indicator.className = 'voice-indicator'; 156 indicator.innerHTML = ` 157 <div class="voice-status"> 158 <span class="status-icon"></span> 159 <span class="status-text">Ready</span> 160 </div> 161 <div class="voice-meter"> 162 <div class="voice-level"></div> 163 </div> 164 `; 165 166 // Insert after textarea 167 this.textarea.parentNode.insertBefore(indicator, this.textarea.nextSibling); 168 this.indicator = indicator; 169 170 // Add styles 171 this.addStyles(); 172 } 173 174 addStyles() { 175 if (document.getElementById('voice-typing-styles')) return; 176 177 const styles = document.createElement('style'); 178 styles.id = 'voice-typing-styles'; 179 styles.textContent = ` 180 .voice-indicator { 181 display: none; 182 padding: 10px; 183 background: #f8f9fa; 184 border-radius: 8px; 185 margin-top: 10px; 186 animation: slideIn 0.3s ease-out; 187 } 188 189 .voice-indicator.active { 190 display: block; 191 } 192 193 .voice-status { 194 display: flex; 195 align-items: center; 196 gap: 8px; 197 margin-bottom: 8px; 198 } 199 200 .status-icon { 201 width: 12px; 202 height: 12px; 203 border-radius: 50%; 204 background: #6c757d; 205 animation: pulse 2s infinite; 206 } 207 208 .status-icon.listening { 209 background: #28a745; 210 animation: pulse 1s infinite; 211 } 212 213 .status-icon.processing { 214 background: #ffc107; 215 animation: spin 1s linear infinite; 216 } 217 218 .status-icon.error { 219 background: #dc3545; 220 animation: none; 221 } 222 223 .voice-meter { 224 height: 4px; 225 background: #e9ecef; 226 border-radius: 2px; 227 overflow: hidden; 228 } 229 230 .voice-level { 231 height: 100%; 232 background: linear-gradient(90deg, #28a745, #20c997); 233 width: 0%; 234 transition: width 0.1s ease-out; 235 } 236 237 @keyframes pulse { 238 0%, 100% { opacity: 1; } 239 50% { opacity: 0.5; } 240 } 241 242 @keyframes spin { 243 0% { transform: rotate(0deg); } 244 100% { transform: rotate(360deg); } 245 } 246 247 @keyframes slideIn { 248 from { 249 opacity: 0; 250 transform: translateY(-10px); 251 } 252 to { 253 opacity: 1;
254 transform: translateY(0); 255 } 256 } 257 258 .voice-tooltip { 259 position: absolute; 260 background: #333; 261 color: white; 262 padding: 5px 10px; 263 border-radius: 4px; 264 font-size: 12px; 265 pointer-events: none; 266 z-index: 1000; 267 opacity: 0; 268 transition: opacity 0.3s; 269 } 270 271 .voice-tooltip.show { 272 opacity: 1; 273 } 274 `; 275 document.head.appendChild(styles); 276 } 277 278 handleStart() { 279 this.state.isListening = true; 280 this.state.errorCount = 0; 281 this.updateStatus('listening', 'Listening...'); 282 283 // Save current text as baseline 284 if (!this.state.finalText) { 285 this.state.finalText = this.textarea.value; 286 } 287 288 // Start silence detection 289 this.startSilenceDetection(); 290 291 // Trigger custom event 292 this.textarea.dispatchEvent(new CustomEvent('voicestart', { 293 detail: { timestamp: Date.now() } 294 })); 295 } 296 297 handleResult(event) { 298 let interimTranscript = ''; 299 let finalTranscript = ''; 300 301 // Process all results 302 for (let i = event.resultIndex; i < event.results.length; i++) { 303 const result = event.results[i]; 304 let transcript = result[0].transcript; 305 306 if (result.isFinal) { 307 // Check for voice commands and extract them 308 if (this.config.commands) { 309 const commandResult = this.extractAndProcessCommand(transcript); 310 if (commandResult.commandFound) { 311 transcript = commandResult.cleanedText; 312 // If the entire text was just a command, skip adding it 313 if (!transcript.trim()) { 314 continue; 315 } 316 } 317 } 318 319 // Process final text 320 finalTranscript += this.processTranscript(transcript); 321 322 // Add to history for undo 323 if (finalTranscript) { 324 this.state.history.push({ 325 text: finalTranscript, 326 timestamp: Date.now() 327 }); 328 } 329 330 // Limit history size 331 if (this.state.history.length > 50) { 332 this.state.history.shift(); 333 } 334 } else { 335 interimTranscript += transcript; 336 } 337 } 338 339 // Update text 340 if (finalTranscript) { 341 this.state.finalText += finalTranscript; 342 } 343 344 this.state.interimText = interimTranscript; 345 this.updateTextarea(); 346 347 // Reset silence timer 348 this.state.lastSpeechTime = Date.now(); 349 350 // Update visual feedback 351 this.updateStatus('listening', 'Listening...'); 352 this.updateVoiceLevel(this.calculateVoiceLevel(event)); 353 354 // Trigger custom event 355 this.textarea.dispatchEvent(new CustomEvent('voiceresult', { 356 detail: { 357 final: finalTranscript, 358 interim: interimTranscript, 359 timestamp: Date.now() 360 } 361 })); 362 } 363 364 processTranscript(text) { 365 let processed = text.trim(); 366 367 // Auto-capitalize first letter 368 if (this.config.autoCapitalize) { 369 if (this.state.finalText.length === 0 || 370 this.state.finalText.match(/[.!?]\s*$/)) { 371 processed = processed.charAt(0).toUpperCase() + processed.slice(1); 372 } 373 } 374 375 // Auto-punctuation (basic) 376 if (this.config.autoPunctuation) { 377 // Add space before if needed 378 if (this.state.finalText.length > 0 && 379 !this.state.finalText.match(/\s$/)) { 380 processed = ' ' + processed; 381 } 382 383 // Add period at end if missing 384 if (processed.length > 10 && !processed.match(/[.!?,;:]$/)) { 385 // Only add period if it seems like end of sentence 386 const lastWords = processed.split(' ').slice(-3).join(' ').toLowerCase(); 387 if (!lastWords.match(/(and|or|but|with|for|to|the|a|an)$/)) { 388 processed += '.'; 389 } 390 } 391 } 392 393 return processed; 394 } 395 396 processCommand(text) { 397 const normalized = text.trim().toLowerCase(); 398 399 for (const [command, action] of Object.entries(this.voiceCommands)) { 400 if (normalized === command || normalized.endsWith(command)) {
401 // Special handling for submit commands 402 if (command === 'enter' || command === 'submit' || command === 'send' || 403 command === 'submit form' || command === 'send message') { 404 action(); 405 } else { 406 action(); 407 this.showTooltip(`Command: ${command}`); 408 } 409 return true; 410 } 411 } 412 413 return false; 414 } 415 416 extractAndProcessCommand(text) { 417 const trimmedText = text.trim(); 418 let commandFound = false; 419 let cleanedText = trimmedText; 420 let executedCommand = null; 421 422 // If removeCommandWords is disabled, just check for commands but don't remove them 423 if (!this.config.removeCommandWords) { 424 const commandFound = this.processCommand(trimmedText); 425 return { 426 commandFound: commandFound, 427 cleanedText: trimmedText, 428 command: null 429 }; 430 } 431 432 // Check each command to see if it appears at the end of the text 433 for (const [command, action] of Object.entries(this.voiceCommands)) { 434 // Create regex patterns for different command positions 435 const patterns = [ 436 // Command at the end with optional punctuation 437 new RegExp(`(.*)\\s+${command.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}[.,!?]?$`, 'i'), 438 // Command alone 439 new RegExp(`^${command.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}[.,!?]?$`, 'i'), 440 // Command at end after punctuation 441 new RegExp(`(.*[.!?])\\s*${command.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}[.,!?]?$`, 'i') 442 ]; 443 444 for (const pattern of patterns) { 445 const match = trimmedText.match(pattern); 446 if (match) { 447 commandFound = true; 448 executedCommand = command; 449 450 // Extract the text without the command 451 if (match[1] !== undefined) { 452 cleanedText = match[1].trim(); 453 } else { 454 cleanedText = ''; 455 } 456 457 // Execute the command after a short delay to allow text update first 458 setTimeout(() => { 459 // Special handling for submit commands 460 if (command === 'enter' || command === 'submit' || command === 'send' || 461 command === 'submit form' || command === 'send message') { 462 action(); 463 } else { 464 action(); 465 this.showTooltip(`Command: ${command}`); 466 } 467 }, 100); 468 469 break; 470 } 471 } 472 473 if (commandFound) break; 474 } 475 476 return { 477 commandFound: commandFound, 478 cleanedText: cleanedText, 479 command: executedCommand 480 }; 481 } 482 483 insertText(text) { 484 const pos = this.state.cursorPosition || this.textarea.selectionStart; 485 const before = this.state.finalText.substring(0, pos); 486 const after = this.state.finalText.substring(pos); 487 488 this.state.finalText = before + text + after; 489 this.state.cursorPosition = pos + text.length; 490 491 this.updateTextarea(); 492 this.textarea.setSelectionRange(this.state.cursorPosition, this.state.cursorPosition); 493 } 494 495 updateTextarea() { 496 const displayText = this.state.finalText + 497 (this.state.interimText ? ' ' + this.state.interimText : ''); 498 499 this.textarea.value = displayText; 500 501 // Trigger input event for form validation 502 this.textarea.dispatchEvent(new Event('input', { bubbles: true })); 503 504 // Auto-scroll to bottom 505 this.textarea.scrollTop = this.textarea.scrollHeight; 506 } 507 508 handleError(event) { 509 this.state.errorCount++; 510 511 let message = 'Voice recognition error'; 512 let shouldRestart = this.config.autoRestart; 513 514 switch (event.error) { 515 case 'no-speech': 516 message = 'No speech detected'; 517 shouldRestart = false; 518 break; 519 case 'audio-capture': 520 message = 'Microphone not found'; 521 shouldRestart = false; 522 this.checkbox.checked = false; 523 break; 524 case 'not-allowed': 525 message = 'Microphone access denied'; 526 shouldRestart = false; 527 this.checkbox.checked = false; 528 break; 529 case 'network': 530 message = 'Network error occurred'; 531 break; 532 case 'aborted': 533 message = 'Recognition aborted'; 534 shouldRestart = false; 535 break; 536 default: 537 message = `Error: ${event.error}`; 538 } 539 540 this.updateStatus('error', message); 541 this.showError(message); 542 543 // Auto-restart if appropriate 544 if (shouldRestart && this.state.errorCount < 3 && this.checkbox.checked && !this.isSubmitting) { 545 setTimeout(() => { 546 if (this.checkbox.checked && !this.isSubmitting) { 547 this.start(); 548 } 549 }, 1000); 550 } else if (this.state.errorCount >= 3) { 551 this.stop(); 552 this.showError('Too many errors. Voice typing stopped.'); 553 } 554 555 // Trigger custom event 556 this.textarea.dispatchEvent(new CustomEvent('voiceerror', { 557 detail: { error: event.error, message } 558 })); 559 } 560 561 handleEnd() { 562 // Only restart if checkbox is still checked and no errors and not submitting 563 if (this.checkbox.checked && this.state.errorCount < 3 && 564 this.config.autoRestart && !this.isSubmitting) { 565 // Small delay to prevent rapid restarts 566 setTimeout(() => { 567 if (this.checkbox.checked && !this.state.isListening && !this.isSubmitting) { 568 this.start(); 569 } 570 }, 100); 571 } else { 572 this.state.isListening = false; 573 this.updateStatus('ready', 'Ready'); 574 this.hideIndicator(); 575 } 576 } 577 578 handleSpeechEnd() { 579 this.updateStatus('processing', 'Processing...'); 580 } 581 582 handleNoMatch() { 583 this.showTooltip('No match found. Try speaking more clearly.'); 584 } 585 586 startSilenceDetection() { 587 // Clear existing timer 588 if (this.state.silenceTimer) { 589 clearInterval(this.state.silenceTimer); 590 } 591 592 // Check for prolonged silence 593 this.state.silenceTimer = setInterval(() => { 594 const silenceDuration = Date.now() - this.state.lastSpeechTime; 595 596 // Auto-stop after 30 seconds of silence 597 if (silenceDuration > 30000) { 598 this.pause(); 599 this.showTooltip('Paused due to inactivity. Click to resume.'); 600 } 601 }, 5000); 602 } 603 604 calculateVoiceLevel(event) { 605 // Simulate voice level based on confidence scores 606 if (!event.results.length) return 0; 607 608 const lastResult = event.results[event.results.length - 1]; 609 const confidence = lastResult[0].confidence || 0.5; 610 611 return Math.min(100, confidence * 120); 612 } 613 614 updateVoiceLevel(level) { 615 if (!this.indicator) return; 616 617 const levelBar = this.indicator.querySelector('.voice-level'); 618 if (levelBar) { 619 levelBar.style.width = `${level}%`; 620 } 621 } 622 623 updateStatus(status, text) { 624 if (!this.indicator) return; 625 626 const statusIcon = this.indicator.querySelector('.status-icon'); 627 const statusText = this.indicator.querySelector('.status-text'); 628 629 if (statusIcon) { 630 statusIcon.className = `status-icon ${status}`; 631 } 632 633 if (statusText) { 634 statusText.textContent = text; 635 } 636 637 // Show/hide indicator 638 if (status === 'listening' || status === 'processing') { 639 this.indicator.classList.add('active'); 640 } 641 } 642 643 hideIndicator() { 644 if (this.indicator) { 645 setTimeout(() => { 646 this.indicator.classList.remove('active'); 647 }, 500); 648 } 649 } 650 651 start() { 652 if (!this.recognition || this.isSubmitting) { 653 if (this.isSubmitting) { 654 this.showError('Cannot start during submission'); 655 } else { 656 this.showError('Speech recognition not initialized'); 657 } 658 return; 659 } 660 661 // Save current text 662 this.state.finalText = this.textarea.value; 663 this.state.interimText = ''; 664 this.state.lastSpeechTime = Date.now(); 665 666 try { 667 this.recognition.start(); 668 this.checkbox.checked = true; 669 } catch (e) { 670 if (e.name === 'InvalidStateError') { 671 // Already started, try to stop and restart 672 this.recognition.stop(); 673 setTimeout(() => this.start(), 100); 674 } else { 675 this.showError('Failed to start: ' + e.message); 676 this.checkbox.checked = false; 677 } 678 } 679 } 680 681 stop() { 682 if (this.recognition) { 683 try { 684 this.recognition.stop(); 685 } catch (e) { 686 console.warn('Error stopping recognition:', e); 687 } 688 } 689 690 if (this.state.silenceTimer) { 691 clearInterval(this.state.silenceTimer); 692 } 693 694 // Clear interim text when stopping 695 this.state.interimText = ''; 696 this.updateTextarea(); 697 698 this.state.isListening = false;
699 this.state.isPaused = false; 700 this.checkbox.checked = false; 701 702 this.updateStatus('ready', 'Stopped'); 703 this.hideIndicator(); 704 705 // Save final state 706 this.saveState(); 707 708 // Trigger custom event 709 this.textarea.dispatchEvent(new CustomEvent('voicestop', { 710 detail: { timestamp: Date.now() } 711 })); 712 } 713 714 forceStop() { 715 // Immediate stop without animations for form submission 716 if (this.recognition) { 717 try { 718 this.recognition.abort(); // Use abort for immediate halt 719 } catch (e) { 720 console.warn('Error aborting recognition:', e); 721 try { 722 this.recognition.stop(); 723 } catch (e2) { 724 console.warn('Error stopping recognition:', e2); 725 } 726 } 727 } 728 729 // Clear all timers 730 if (this.state.silenceTimer) { 731 clearInterval(this.state.silenceTimer); 732 } 733 734 // Finalize any interim text 735 if (this.state.interimText) { 736 this.state.finalText += ' ' + this.state.interimText; 737 this.state.interimText = ''; 738 } 739 740 // Update textarea with final text only 741 this.textarea.value = this.state.finalText.trim(); 742 743 // Reset state immediately 744 this.state.isListening = false; 745 this.state.isPaused = false; 746 this.checkbox.checked = false; 747 748 // Hide indicators immediately 749 if (this.indicator) { 750 this.indicator.classList.remove('active'); 751 } 752 753 this.updateStatus('ready', 'Stopped'); 754 } 755 756 pause() { 757 if (this.recognition && this.state.isListening) { 758 this.recognition.stop(); 759 this.state.isPaused = true; 760 this.updateStatus('ready', 'Paused'); 761 this.showTooltip('Voice typing paused'); 762 } 763 } 764 765 resume() { 766 if (this.state.isPaused && !this.isSubmitting) { 767 this.state.isPaused = false; 768 this.start(); 769 this.showTooltip('Voice typing resumed'); 770 } 771 } 772 773 clearText() { 774 this.state.history.push({ 775 text: this.state.finalText, 776 timestamp: Date.now() 777 }); 778 779 this.state.finalText = ''; 780 this.state.interimText = ''; 781 this.updateTextarea(); 782 this.showTooltip('Text cleared'); 783 } 784 785 submitForm() { 786 // Check if auto-submit is enabled 787 if (!this.config.autoSubmit) { 788 this.showTooltip('Auto-submit is disabled', 'info'); 789 return; 790 } 791 792 // Prevent duplicate submissions 793 if (this.isSubmitting) { 794 return; 795 } 796 this.isSubmitting = true; 797 798 // Optional confirmation 799 if (this.config.confirmSubmit) { 800 if (!confirm('Submit the message?')) { 801 this.isSubmitting = false; 802 if (this.checkbox.checked) { 803 setTimeout(() => this.start(), 100); 804 } 805 return; 806 } 807 } 808 809 // Force stop voice recognition 810 this.forceStop(); 811 812 // Find and submit the form 813 const form = this.textarea.closest('form'); 814 if (form) { 815 // Ensure the textarea has the final text (without interim) 816 this.textarea.value = this.state.finalText.trim(); 817 818 if (this.textarea.value.trim()) { 819 this.showTooltip('Submitting...'); 820 821 // Use requestSubmit for proper event handling 822 if (form.requestSubmit) { 823 form.requestSubmit(); 824 } else { 825 const submitBtn = form.querySelector('[type="submit"]'); 826 if (submitBtn) { 827 submitBtn.click(); 828 } else { 829 form.submit(); 830 } 831 } 832 833 // Trigger custom event 834 this.textarea.dispatchEvent(new CustomEvent('voicesubmit', { 835 detail: { 836 text: this.textarea.value, 837 timestamp: Date.now() 838 } 839 })); 840 841 // Reset submission flag after delay 842 setTimeout(() => { 843 this.isSubmitting = false; 844 }, 1000); 845 } else { 846 this.isSubmitting = false; 847 this.showTooltip('Cannot submit empty message', 'warning'); 848 setTimeout(() => { 849 if (this.checkbox.checked) { 850 this.start(); 851 } 852 }, 1000); 853 } 854 } else { 855 this.isSubmitting = false; 856 console.warn('No form found for submission'); 857 this.showTooltip('Form not found', 'error'); 858 } 859 } 860 861 undo() { 862 if (this.state.history.length > 0) { 863 const previous = this.state.history.pop(); 864 this.state.finalText = previous.text; 865 this.updateTextarea(); 866 this.showTooltip('Undo successful'); 867 } 868 } 869 870 showError(message) { 871 console.error('[VoiceTyping]', message); 872 this.showTooltip(message, 'error'); 873 } 874 875 showTooltip(message, type = 'info') { 876 // Remove existing tooltip 877 const existing = document.querySelector('.voice-tooltip'); 878 if (existing) { 879 existing.remove(); 880 } 881 882 // Create new tooltip 883 const tooltip = document.createElement('div'); 884 tooltip.className = 'voice-tooltip'; 885 tooltip.textContent = message; 886 887 // Position near checkbox 888 const rect = this.checkbox.getBoundingClientRect(); 889 tooltip.style.left = `${rect.left}px`; 890 tooltip.style.top = `${rect.bottom + 10}px`; 891 892 document.body.appendChild(tooltip); 893 894 // Show tooltip 895 setTimeout(() => tooltip.classList.add('show'), 10); 896 897 // Auto-hide 898 setTimeout(() => { 899 tooltip.classList.remove('show'); 900 setTimeout(() => tooltip.remove(), 300); 901 }, 3000); 902 } 903 904 saveState() { 905 const state = { 906 text: this.textarea.value, 907 timestamp: Date.now() 908 }; 909 910 try { 911 localStorage.setItem(`voice-typing-${this.textarea.id}`, JSON.stringify(state)); 912 } catch (e) { 913 console.warn('Failed to save state:', e); 914 } 915 } 916 917 loadSavedState() { 918 try { 919 const saved = localStorage.getItem(`voice-typing-${this.textarea.id}`); 920 if (saved) { 921 const state = JSON.parse(saved); 922 923 // Only restore if less than 24 hours old
924 if (Date.now() - state.timestamp < 86400000) { 925 if (!this.textarea.value || this.textarea.value === '') { 926 this.textarea.value = state.text; 927 } 928 } 929 } 930 } catch (e) { 931 console.warn('Failed to load state:', e); 932 } 933 } 934 935 // Public API methods 936 setLanguage(lang) { 937 this.config.lang = lang; 938 if (this.recognition) { 939 this.recognition.lang = lang; 940 } 941 942 // Restart if currently listening 943 if (this.state.isListening) { 944 this.stop(); 945 setTimeout(() => this.start(), 100); 946 } 947 } 948 949 addCommand(trigger, action) { 950 this.voiceCommands[trigger.toLowerCase()] = action; 951 } 952 953 removeCommand(trigger) { 954 delete this.voiceCommands[trigger.toLowerCase()]; 955 } 956 957 destroy() { 958 this.forceStop(); 959 960 if (this.indicator) { 961 this.indicator.remove(); 962 } 963 964 // Clear state 965 this.state = null; 966 this.recognition = null; 967 } 968} 969 970// Initialize with enhanced features 971const voiceTyping = new EnhancedVoiceTyping('asked-question', 'voicememo', { 972 lang: 'en-US', 973 autoRestart: true, 974 autoCapitalize: true, 975 autoPunctuation: true, 976 commands: true, 977 visualFeedback: true, 978 continuous: true, 979 interimResults: true, 980 autoSubmit: true, 981 confirmSubmit: false, 982 removeCommandWords: true 983}); 984 985// Expose to global scope 986window.voiceTyping = voiceTyping; 987 988// Global error handler for voice recognition 989window.addEventListener('unhandledrejection', function (event) { 990 if (window.voiceTyping && window.voiceTyping.state && window.voiceTyping.state.isListening) { 991 console.error('Voice typing error:', event.reason); 992 window.voiceTyping.forceStop(); 993 } 994});
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.