שיתוף | תוסף וסקריפט לתמלול דיבור לטקסט בכל אתר - ללא API
-
@המלאך נכון, אבל דווקא יש סיבה - לתמלול טקסט לדיבור... לא? (כלומר זה ספציפית באמת לא, אבל כל דבר אחר כזה - כן)
@אברהם-גלסר זה רק איפה שאין מובנה.
אז צריך להשתמש בספריות וכו'.
לכן בימות שאין את זה בשרתים שלהם צריך.
אבל לא כאן..
בכל מקרה זה רעיון טוב. -
בניתי בעזרת ChatGPT כלי קטן שמוסיף כפתור מיקרופון קבוע בדפדפן, ומאפשר להכתיב טקסט ישירות לתוך שדות כתיבה באתרים.

מה הכלי עושה בפועל:
מוסיף כפתור מיקרופון צף בכל אתר.
כברירת מחדל מוצג רק כפתור התמלול עצמו, בלי תפריטים מסביב.
יש חץ קטן לפתיחת כל האפשרויות.
בלחיצה על החץ נפתחות האפשרויות: בחירת שפה, הגדרות, טקסט שתומלל, העתקה/הדבקה וכדומה.
בלחיצה נוספת על החץ הכל נסגר שוב.בלחיצה על כפתור המיקרופון מתחיל תמלול דיבור לטקסט.
לחיצה נוספת עוצרת את התמלול.
הטקסט נכנס בזמן אמת לשדה הכתיבה הפעיל.
עובד עם textarea, input ושדות כתיבה מתקדמים יותר כמו contenteditable.
תומך בעברית, אנגלית ובכמה שפות נוספות.
יש אפשרות לבחור שפת תמלול.
אם אין שדה כתיבה פעיל, הטקסט מופיע בחלונית של הכלי ואפשר להעתיק אותו.אפשר להזיז את כפתור התמלול עם Alt + גרירה על הכפתור.
המיקום נשמר גם אחרי רענון.
המיקום נשמר בנפרד לכל אתר, כלומר אפשר לקבוע מיקום אחד ל-ChatGPT, מיקום אחר לפורום וכו׳.
ככה תוכלו להתאים את המיקום שלו לכל אתר איפה שהכי מתאים לו להיות, למשל בChatGPT:
אם הכפתור נעלם בגלל שהוזז החוצה, אפשר להחזיר אותו למיקום ברירת המחדל באתר הנוכחי עם:
Alt + Shift + M
או:
Ctrl + Alt + Mבנוסף, אם מוחקים ידנית תוך כדי תמלול טקסט שהכלי כתב בתוך תיבת הטקסט, הכלי מתעלם מהמחיקה ולא מחזיר את הטקסט הזה מחדש בסיום ההקלטה.
הכלי מבוסס על מנגנון זיהוי הדיבור של הדפדפן, כך שאין צורך בשרת חיצוני או במפתח API משלכם. בפעם הראשונה הדפדפן יבקש הרשאת מיקרופון.
יש שתי אפשרויות שימוש:
אפשרות 1 - סקריפט Tampermonkey
מתאים למי שכבר משתמש ב־Tampermonkey או רוצה להתקין userscript.
התקנה:
מתקינים Tampermonkey.
יוצרים סקריפט חדש.
מדביקים את הקוד.
שומרים.
מרעננים את האתר שבו רוצים להשתמש.הקוד לסקריפט:
// ==UserScript== // @name Universal Voice to Text - Floating Dictation Button // @namespace https://chat.openai.com/ // @version 2.6.0 // @description Floating draggable speech-to-text button for almost any website. Dictates into focused inputs, textareas and contenteditable editors. // @author Avraham + ChatGPT // @match http://*/* // @match https://*/* // @run-at document-idle // @grant GM_getValue // @grant GM_setValue // @grant GM_registerMenuCommand // ==/UserScript== (() => { 'use strict'; const APP_ID = 'uvtt-floating-root'; const STORAGE_PREFIX = 'uvtt_tm_'; const INTERIM_ATTR = 'data-uvtt-interim'; const DEFAULT_SETTINGS = { language: 'he-IL', continuous: true, autoCapitalize: true, addSpaceBeforeText: true, showStatus: true, showLanguageSelect: true }; const DEFAULT_POSITION = { mode: 'corner', right: 24, bottom: 24, left: null, top: null }; const LANGUAGES = [ { value: 'he-IL', label: 'עברית' }, { value: 'en-US', label: 'English US' }, { value: 'en-GB', label: 'English UK' }, { value: 'ar-SA', label: 'العربية' }, { value: 'fr-FR', label: 'Français' }, { value: 'es-ES', label: 'Español' }, { value: 'ru-RU', label: 'Русский' } ]; const INPUT_TYPES = new Set([ '', 'text', 'search', 'email', 'url', 'tel', 'password', 'number' ]); const state = { mounted: false, listening: false, recognition: null, settings: { ...DEFAULT_SETTINGS }, position: { ...DEFAULT_POSITION }, currentEditor: null, lastFocusedEditor: null, statusText: 'מוכן', suppressNextEndRestart: false, lastTranscriptAt: 0, bufferText: '', discardUntilFinal: false, interim: { editor: null, type: null, node: null, prefix: '', rawText: '', previewText: '', inputStart: null, inputLength: 0 }, dragging: { active: false, moved: false, pointerId: null, startedOnMic: false, canDrag: false, startX: 0, startY: 0, startLeft: 0, startTop: 0 } }; let host; let shadow; let wrap; let micButton; let expandButton; let statusPill; let languageSelect; let settingsButton; let settingsPanel; let bufferPreview; let copyButton; let pasteButton; let clearButton; let resetPositionButton; function gmGet(key, fallback) { try { if (typeof GM_getValue === 'function') { return GM_getValue(`${STORAGE_PREFIX}${key}`, fallback); } } catch (_) {} try { const raw = localStorage.getItem(`${STORAGE_PREFIX}${key}`); return raw === null ? fallback : JSON.parse(raw); } catch (_) { return fallback; } } function gmSet(key, value) { try { if (typeof GM_setValue === 'function') { GM_setValue(`${STORAGE_PREFIX}${key}`, value); return; } } catch (_) {} try { localStorage.setItem(`${STORAGE_PREFIX}${key}`, JSON.stringify(value)); } catch (_) {} } function getSiteStorageKey(name) { const origin = (() => { try { return window.location.origin || `${window.location.protocol}//${window.location.host}`; } catch (_) { return 'unknown-origin'; } })(); return `${name}:${origin}`; } function getPositionStorageKey() { return getSiteStorageKey('position'); } function loadSettings() { const loaded = { ...DEFAULT_SETTINGS }; for (const key of Object.keys(DEFAULT_SETTINGS)) { loaded[key] = gmGet(key, DEFAULT_SETTINGS[key]); } state.settings = loaded; const loadedPosition = gmGet(getPositionStorageKey(), DEFAULT_POSITION); state.position = { ...DEFAULT_POSITION, ...(loadedPosition && typeof loadedPosition === 'object' ? loadedPosition : {}) }; } function saveSetting(key, value) { state.settings[key] = value; gmSet(key, value); if (key === 'language' && state.recognition && state.listening) { setStatus('השפה תעודכן בהפעלה הבאה'); } refreshUiVisibilitySettings(); } function savePosition(position) { state.position = { ...state.position, ...position }; gmSet(getPositionStorageKey(), state.position); } function supportsSpeechRecognition() { return Boolean(window.SpeechRecognition || window.webkitSpeechRecognition); } function getSpeechRecognitionCtor() { return window.SpeechRecognition || window.webkitSpeechRecognition; } function isInsideOwnUi(node) { if (!node) return false; return node === host || (host && host.contains(node)); } function isEditableElement(element) { if (!element || !(element instanceof HTMLElement)) return false; if (isInsideOwnUi(element)) return false; if (element.isContentEditable) return true; const tag = element.tagName.toLowerCase(); if (tag === 'textarea') return !element.disabled && !element.readOnly; if (tag === 'input') { const input = element; return INPUT_TYPES.has((input.getAttribute('type') || '').toLowerCase()) && !input.disabled && !input.readOnly; } return false; } function getEditorType(editor) { if (!editor) return null; const tag = editor.tagName?.toLowerCase(); if (tag === 'textarea' || tag === 'input') return 'input'; if (editor.isContentEditable) return 'contenteditable'; return null; } function isElementVisible(el) { if (!el || !(el instanceof HTMLElement)) return false; const rect = el.getBoundingClientRect(); const style = getComputedStyle(el); return rect.width > 0 && rect.height > 0 && style.visibility !== 'hidden' && style.display !== 'none'; } function getCandidateEditors() { const selectors = [ 'textarea', 'input[type="text"]', 'input[type="search"]', 'input[type="email"]', 'input[type="url"]', 'input[type="tel"]', 'input[type="password"]', 'input[type="number"]', 'input:not([type])', '[contenteditable="true"]', '[contenteditable="plaintext-only"]', '[role="textbox"]' ]; return Array.from(document.querySelectorAll(selectors.join(','))) .filter((el) => isEditableElement(el) && isElementVisible(el)); } function getDeepActiveElement(root = document) { let active = root.activeElement; while (active && active.shadowRoot && active.shadowRoot.activeElement) { active = active.shadowRoot.activeElement; } return active; } function getBestEditor() { const active = getDeepActiveElement(); if (isEditableElement(active) && isElementVisible(active)) return active; if (state.lastFocusedEditor && isEditableElement(state.lastFocusedEditor) && isElementVisible(state.lastFocusedEditor)) { return state.lastFocusedEditor; } if (state.currentEditor && isEditableElement(state.currentEditor) && isElementVisible(state.currentEditor)) { return state.currentEditor; } const selection = window.getSelection(); if (selection && selection.anchorNode) { const candidates = getCandidateEditors(); for (const editor of candidates) { if (editor.contains(selection.anchorNode)) return editor; } } return null; } function setStatus(text) { state.statusText = text; if (statusPill) statusPill.textContent = text; } function getLanguageLabel(value) { return LANGUAGES.find((lang) => lang.value === value)?.label || value; } function normalizeTranscript(rawText) { let text = (rawText || '').replace(/\s+/g, ' ').trim(); if (!text) return ''; if (state.settings.autoCapitalize && state.settings.language.startsWith('en')) { text = text.charAt(0).toUpperCase() + text.slice(1); } return text; } function getInputPrefix(editor, start) { if (!state.settings.addSpaceBeforeText) return ''; const left = String(editor.value || '').slice(0, Math.max(0, start ?? editor.selectionStart ?? 0)); if (!left) return ''; return /[\s\n]$/.test(left) ? '' : ' '; } function editorContainsSelection(editor) { const selection = window.getSelection(); if (!selection || selection.rangeCount === 0) return false; return !!selection.anchorNode && editor.contains(selection.anchorNode); } function moveCaretToEndContentEditable(editor) { editor.focus(); const selection = window.getSelection(); if (!selection) return; const range = document.createRange(); range.selectNodeContents(editor); range.collapse(false); selection.removeAllRanges(); selection.addRange(range); } function getPlainText(editor) { return (editor.innerText || editor.textContent || '').replace(/\u00a0/g, ' '); } function getContentEditablePrefix(editor) { if (!state.settings.addSpaceBeforeText) return ''; let leftText = ''; const selection = window.getSelection(); if (selection && selection.rangeCount > 0 && editorContainsSelection(editor)) { try { const probe = selection.getRangeAt(0).cloneRange(); probe.setStart(editor, 0); leftText = probe.toString(); } catch (_) { leftText = getPlainText(editor); } } else { leftText = getPlainText(editor); } if (!leftText) return ''; return /[\s\n]$/.test(leftText) ? '' : ' '; } function dispatchInput(editor, data = '') { try { editor.dispatchEvent(new InputEvent('input', { inputType: 'insertText', data, bubbles: true, composed: true })); } catch (_) { editor.dispatchEvent(new Event('input', { bubbles: true })); } } function dispatchContentEditableDomChange(editor) { // In React/ProseMirror-style editors, sending InputEvent data for every // interim result can make the app append each partial transcript again. // We already changed the DOM ourselves, so notify the editor with a // neutral input event instead of another "insertText" payload. try { editor.dispatchEvent(new InputEvent('input', { inputType: 'insertReplacementText', data: null, bubbles: true, composed: true })); } catch (_) { editor.dispatchEvent(new Event('input', { bubbles: true })); } } function setInputRangeText(editor, replacement, start, end) { editor.focus(); try { editor.setRangeText(replacement, start, end, 'end'); } catch (_) { const value = String(editor.value || ''); editor.value = `${value.slice(0, start)}${replacement}${value.slice(end)}`; const caret = start + replacement.length; try { editor.setSelectionRange(caret, caret); } catch (_) {} } dispatchInput(editor, replacement); } function insertTextIntoInput(editor, rawText) { const normalized = normalizeTranscript(rawText); if (!normalized) return false; editor.focus(); const start = editor.selectionStart ?? String(editor.value || '').length; const end = editor.selectionEnd ?? start; const text = `${getInputPrefix(editor, start)}${normalized}`; setInputRangeText(editor, text, start, end); return true; } function getTextPositionInContentEditable(editor, charOffset) { const target = Math.max(0, Number(charOffset) || 0); const walker = document.createTreeWalker(editor, NodeFilter.SHOW_TEXT); let remaining = target; let lastPosition = null; let node; while ((node = walker.nextNode())) { const length = node.nodeValue.length; if (remaining <= length) return { node, offset: remaining }; remaining -= length; lastPosition = { node, offset: length }; } return lastPosition || { node: editor, offset: 0 }; } function selectContentEditableTextRange(editor, startOffset, endOffset) { const start = getTextPositionInContentEditable(editor, startOffset); const end = getTextPositionInContentEditable(editor, endOffset); if (!start || !end) return false; try { const range = document.createRange(); range.setStart(start.node, start.offset); range.setEnd(end.node, end.offset); const selection = window.getSelection(); if (!selection) return false; selection.removeAllRanges(); selection.addRange(range); return true; } catch (_) { return false; } } function selectLastCharactersBeforeCaret(editor, charCount) { const length = Math.max(0, Number(charCount) || 0); if (!length) return true; editor.focus(); if (!editorContainsSelection(editor)) moveCaretToEndContentEditable(editor); const selection = window.getSelection(); if (!selection || selection.rangeCount === 0) return false; try { const caret = selection.getRangeAt(0).cloneRange(); caret.collapse(false); const beforeCaret = caret.cloneRange(); beforeCaret.selectNodeContents(editor); beforeCaret.setEnd(caret.endContainer, caret.endOffset); const endOffset = beforeCaret.toString().length; const startOffset = Math.max(0, endOffset - length); return selectContentEditableTextRange(editor, startOffset, endOffset); } catch (_) { return false; } } function selectLastMatchingText(editor, text) { const needle = String(text || ''); if (!needle) return false; const haystack = getPlainText(editor); const startOffset = haystack.lastIndexOf(needle); if (startOffset < 0) return false; return selectContentEditableTextRange(editor, startOffset, startOffset + needle.length); } function replaceContentEditableSelection(editor, text) { editor.focus(); let inserted = false; try { if (document.queryCommandSupported && document.queryCommandSupported('insertText')) { inserted = document.execCommand('insertText', false, text); } } catch (_) { inserted = false; } if (!inserted) { const selection = window.getSelection(); if (!selection || selection.rangeCount === 0) return false; const range = selection.getRangeAt(0); range.deleteContents(); const textNode = document.createTextNode(text); range.insertNode(textNode); range.setStartAfter(textNode); range.setEndAfter(textNode); selection.removeAllRanges(); selection.addRange(range); dispatchContentEditableDomChange(editor); } return true; } function replaceTrackedContentEditableText(editor, oldLength, oldText, newText) { editor.focus(); const previous = String(oldText || ''); if (oldLength > 0 || previous) { // Never replace arbitrary text near the caret. If the live preview was // manually deleted/changed by the user, do not bring it back later. if (!previous || !selectLastMatchingText(editor, previous)) return false; } return replaceContentEditableSelection(editor, newText); } function insertTextIntoContentEditable(editor, rawText) { const normalized = normalizeTranscript(rawText); if (!normalized) return false; const text = `${getContentEditablePrefix(editor)}${normalized}`; editor.focus(); if (!editorContainsSelection(editor)) { moveCaretToEndContentEditable(editor); } let inserted = false; try { if (document.queryCommandSupported && document.queryCommandSupported('insertText')) { inserted = document.execCommand('insertText', false, text); } } catch (_) { inserted = false; } if (!inserted) { const selection = window.getSelection(); if (!selection || selection.rangeCount === 0) return false; const range = selection.getRangeAt(0); range.deleteContents(); const textNode = document.createTextNode(text); range.insertNode(textNode); range.setStartAfter(textNode); range.setEndAfter(textNode); selection.removeAllRanges(); selection.addRange(range); dispatchContentEditableDomChange(editor); } return true; } function insertTextIntoEditor(editor, rawText) { const type = getEditorType(editor); if (type === 'input') return insertTextIntoInput(editor, rawText); if (type === 'contenteditable') return insertTextIntoContentEditable(editor, rawText); return false; } function resetInterimState() { state.interim = { editor: null, type: null, node: null, prefix: '', rawText: '', previewText: '', inputStart: null, inputLength: 0 }; } function clearInterim({ keepVisualText = false, dispatch = true } = {}) { const interim = state.interim; if (!interim.editor) { resetInterimState(); return; } if (interim.type === 'contenteditable' && interim.node && document.contains(interim.node)) { if (keepVisualText && interim.node.textContent) { const textNode = document.createTextNode(interim.node.textContent); const parent = interim.node.parentNode; if (parent) { parent.replaceChild(textNode, interim.node); const selection = window.getSelection(); if (selection) { const range = document.createRange(); range.setStartAfter(textNode); range.collapse(true); selection.removeAllRanges(); selection.addRange(range); } } } else { interim.node.remove(); } if (dispatch) dispatchContentEditableDomChange(interim.editor); } if (interim.type === 'input' && interim.inputStart !== null && !keepVisualText) { const start = interim.inputStart; const end = start + interim.inputLength; setInputRangeText(interim.editor, '', start, end); } resetInterimState(); } function inputPreviewIsIntact(interim = state.interim) { if (!interim.editor || interim.type !== 'input' || interim.inputStart === null || interim.inputLength <= 0) return true; const value = String(interim.editor.value || ''); const expected = String(interim.previewText || ''); if (!expected) return true; const exact = value.slice(interim.inputStart, interim.inputStart + interim.inputLength) === expected; if (exact) return true; const fallbackIndex = value.lastIndexOf(expected); if (fallbackIndex >= 0) { interim.inputStart = fallbackIndex; return true; } return false; } function contentEditablePreviewIsIntact(interim = state.interim) { if (!interim.editor || interim.type !== 'contenteditable' || interim.inputLength <= 0) return true; const expected = String(interim.previewText || ''); if (!expected) return true; return getPlainText(interim.editor).includes(expected); } function interimPreviewIsIntact() { if (!state.interim.editor) return true; if (state.interim.type === 'input') return inputPreviewIsIntact(); if (state.interim.type === 'contenteditable') return contentEditablePreviewIsIntact(); return true; } function discardCurrentSpeechSegment() { state.discardUntilFinal = true; resetInterimState(); updateBufferPreview(); setStatus('הטקסט נמחק — מדלג על המקטע'); } function renderInterimInInput(editor, rawText) { const normalized = normalizeTranscript(rawText); if (!normalized) return false; editor.focus(); if (state.interim.editor !== editor || state.interim.type !== 'input') { clearInterim({ keepVisualText: true }); const start = editor.selectionStart ?? String(editor.value || '').length; state.interim.editor = editor; state.interim.type = 'input'; state.interim.inputStart = start; state.interim.prefix = getInputPrefix(editor, start); state.interim.inputLength = 0; state.interim.previewText = ''; } else if (!inputPreviewIsIntact()) { discardCurrentSpeechSegment(); return false; } const text = `${state.interim.prefix}${normalized}`; const start = state.interim.inputStart; const end = start + state.interim.inputLength; setInputRangeText(editor, text, start, end); state.interim.inputLength = text.length; state.interim.previewText = text; state.interim.rawText = rawText; return true; } function ensureInterimNode(editor) { if (state.interim.editor === editor && state.interim.type === 'contenteditable' && state.interim.node && document.contains(state.interim.node)) { return state.interim.node; } clearInterim({ keepVisualText: true }); editor.focus(); if (!editorContainsSelection(editor)) { moveCaretToEndContentEditable(editor); } const selection = window.getSelection(); if (!selection || selection.rangeCount === 0) return null; const span = document.createElement('span'); span.setAttribute(INTERIM_ATTR, '1'); span.style.opacity = '0.72'; span.style.whiteSpace = 'pre-wrap'; span.style.borderBottom = '1px dotted currentColor'; state.interim.editor = editor; state.interim.type = 'contenteditable'; state.interim.node = span; state.interim.prefix = getContentEditablePrefix(editor); state.interim.rawText = ''; span.textContent = state.interim.prefix; const range = selection.getRangeAt(0); range.deleteContents(); range.insertNode(span); const after = document.createRange(); after.setStartAfter(span); after.collapse(true); selection.removeAllRanges(); selection.addRange(after); return span; } function renderInterimInContentEditable(editor, rawText) { const normalized = normalizeTranscript(rawText); if (!normalized) return false; if (state.interim.editor !== editor || state.interim.type !== 'contenteditable') { clearInterim({ keepVisualText: true }); editor.focus(); if (!editorContainsSelection(editor)) moveCaretToEndContentEditable(editor); state.interim.editor = editor; state.interim.type = 'contenteditable'; state.interim.node = null; state.interim.prefix = getContentEditablePrefix(editor); state.interim.inputLength = 0; state.interim.previewText = ''; state.interim.rawText = ''; } const text = `${state.interim.prefix}${normalized}`; const ok = replaceTrackedContentEditableText( editor, state.interim.inputLength, state.interim.previewText, text ); if (!ok) { discardCurrentSpeechSegment(); return false; } state.interim.inputLength = text.length; state.interim.previewText = text; state.interim.rawText = rawText; return true; } function renderInterimTranscript(editor, rawText) { const type = getEditorType(editor); if (type === 'input') return renderInterimInInput(editor, rawText); if (type === 'contenteditable') return renderInterimInContentEditable(editor, rawText); return false; } function commitFinalToInput(editor, rawText) { const normalized = normalizeTranscript(rawText); if (!normalized) return false; if (state.interim.editor === editor && state.interim.type === 'input' && state.interim.inputStart !== null) { if (!inputPreviewIsIntact()) { resetInterimState(); setStatus('דילג על הטקסט שנמחק'); return false; } const text = `${state.interim.prefix}${normalized}`; const start = state.interim.inputStart; const end = start + state.interim.inputLength; setInputRangeText(editor, text, start, end); resetInterimState(); return true; } return insertTextIntoInput(editor, normalized); } function commitFinalToContentEditable(editor, rawText) { const normalized = normalizeTranscript(rawText); if (!normalized) return false; if (state.interim.editor === editor && state.interim.type === 'contenteditable') { const finalText = `${state.interim.prefix}${normalized}`; const ok = replaceTrackedContentEditableText( editor, state.interim.inputLength, state.interim.previewText, finalText ); resetInterimState(); if (!ok) setStatus('דילג על הטקסט שנמחק'); return ok; } return insertTextIntoContentEditable(editor, normalized); } function commitFinalTranscript(editor, rawText) { const type = getEditorType(editor); if (type === 'input') return commitFinalToInput(editor, rawText); if (type === 'contenteditable') return commitFinalToContentEditable(editor, rawText); return false; } function appendToBuffer(rawText) { const normalized = normalizeTranscript(rawText); if (!normalized) return; const prefix = state.bufferText && !/[\s\n]$/.test(state.bufferText) ? ' ' : ''; state.bufferText = `${state.bufferText}${prefix}${normalized}`; updateBufferPreview(); } function updateBufferPreview(interimText = '') { if (!bufferPreview) return; const interim = normalizeTranscript(interimText); const full = `${state.bufferText}${interim ? `${state.bufferText ? ' ' : ''}${interim}` : ''}`.trim(); bufferPreview.textContent = full || 'אין טקסט שמור עדיין.'; bufferPreview.classList.toggle('empty', !full); } async function copyBufferToClipboard() { const text = state.bufferText.trim(); if (!text) { setStatus('אין טקסט להעתקה'); return; } try { await navigator.clipboard.writeText(text); setStatus('הטקסט הועתק'); } catch (_) { setStatus('לא ניתן להעתיק אוטומטית'); } } function pasteBufferToActiveEditor() { const text = state.bufferText.trim(); if (!text) { setStatus('אין טקסט להדבקה'); return; } const editor = getBestEditor(); if (!editor) { setStatus('בחר קודם שדה טקסט בדף'); return; } if (insertTextIntoEditor(editor, text)) { state.bufferText = ''; updateBufferPreview(); setStatus('הטקסט הודבק לשדה הפעיל'); } } function updateButtonState() { if (!micButton) return; const supported = supportsSpeechRecognition(); micButton.classList.toggle('recording', state.listening); micButton.classList.toggle('unsupported', !supported); micButton.title = supported ? (state.listening ? 'עצור תמלול · Alt+גרירה להזזה' : 'התחל תמלול · Alt+גרירה להזזה') : 'הדפדפן לא תומך בתמלול קולי'; micButton.setAttribute('aria-pressed', state.listening ? 'true' : 'false'); if (!supported) { setStatus('לא נתמך בדפדפן הזה'); } } function refreshUiVisibilitySettings() { if (!wrap) return; wrap.classList.toggle('hide-status', !state.settings.showStatus); wrap.classList.toggle('hide-lang', !state.settings.showLanguageSelect); } function applyPosition() { if (!wrap) return; if (state.position.mode === 'free' && Number.isFinite(state.position.left) && Number.isFinite(state.position.top)) { // Intentionally do not clamp to the viewport. Users can move the button // partially or fully outside the visible page, and restore it with // Alt+Shift+M or the reset button when the panel is visible. wrap.style.left = `${state.position.left}px`; wrap.style.top = `${state.position.top}px`; wrap.style.right = 'auto'; wrap.style.bottom = 'auto'; return; } wrap.style.left = 'auto'; wrap.style.top = 'auto'; wrap.style.right = `${state.position.right ?? 24}px`; wrap.style.bottom = `${state.position.bottom ?? 24}px`; } function resetPosition() { savePosition({ ...DEFAULT_POSITION }); requestAnimationFrame(applyPosition); setStatus('המיקום אופס לאתר הזה'); } function ensureUi() { if (state.mounted) return; host = document.createElement('div'); host.id = APP_ID; host.setAttribute('aria-hidden', 'false'); document.documentElement.appendChild(host); shadow = host.attachShadow({ mode: 'open' }); shadow.innerHTML = ` <style> :host { all: initial; } .wrap { position: fixed; z-index: 2147483647; display: inline-flex; align-items: center; gap: 8px; direction: rtl; font-family: Arial, sans-serif; user-select: none; touch-action: none; } .options { display: inline-flex; align-items: center; gap: 8px; } .wrap.collapsed .options, .wrap.collapsed .panel { display: none !important; } .status, .lang, .settings-toggle, .expand-toggle { background: rgba(17, 24, 39, 0.94); color: #fff; border-radius: 999px; box-shadow: 0 8px 20px rgba(0,0,0,0.18); } .status { padding: 7px 11px; font-size: 12px; line-height: 1; white-space: nowrap; max-width: min(280px, 40vw); overflow: hidden; text-overflow: ellipsis; } .hide-status .status { display: none; } .hide-lang .lang { display: none; } .lang { border: 0; outline: 0; padding: 7px 10px; font-size: 12px; cursor: pointer; max-width: 125px; } .mic-shell { position: relative; width: 54px; height: 54px; display: inline-flex; align-items: center; justify-content: center; flex: 0 0 auto; } .btn, .settings-toggle, .expand-toggle { border: none; cursor: pointer; display: inline-flex; align-items: center; justify-content: center; transition: transform 120ms ease, box-shadow 120ms ease, opacity 120ms ease, background 120ms ease; } .btn { width: 54px; height: 54px; border-radius: 999px; background: #1a73e8; color: #fff; box-shadow: 0 12px 26px rgba(26, 115, 232, 0.34); position: relative; } .btn:hover, .settings-toggle:hover, .expand-toggle:hover { transform: translateY(-1px); } .btn:active, .settings-toggle:active, .expand-toggle:active { transform: translateY(0); } .btn.recording { background: #d93025; box-shadow: 0 12px 26px rgba(217, 48, 37, 0.38); animation: pulse 1.2s infinite; } .btn.unsupported { cursor: not-allowed; opacity: 0.65; box-shadow: none; animation: none; } .wrap.dragging .btn { cursor: grabbing; } .settings-toggle { width: 34px; height: 34px; font-size: 16px; } .expand-toggle { position: absolute; left: -3px; bottom: -3px; z-index: 2; width: 22px; height: 22px; padding: 0; font-size: 12px; font-weight: 700; line-height: 1; box-shadow: 0 6px 14px rgba(0,0,0,0.22); } .wrap.expanded .expand-toggle { background: rgba(17, 24, 39, 0.98); } .icon { width: 26px; height: 26px; fill: currentColor; pointer-events: none; } .panel { position: absolute; right: 0; bottom: 66px; display: none; width: min(320px, calc(100vw - 24px)); padding: 12px; border-radius: 16px; background: rgba(255,255,255,0.99); color: #202124; box-shadow: 0 16px 42px rgba(0,0,0,0.24); border: 1px solid rgba(0,0,0,0.08); } .panel.open { display: block; } .panel-title { font-weight: 700; margin: 0 0 10px; font-size: 14px; } .row { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin: 9px 0; font-size: 13px; } .row label { cursor: pointer; } .panel select { max-width: 150px; border: 1px solid #dadce0; border-radius: 8px; padding: 5px 7px; background: #fff; } .buffer { margin-top: 10px; padding: 9px; border: 1px solid #e0e0e0; border-radius: 12px; background: #f8fafd; max-height: 110px; overflow: auto; white-space: pre-wrap; line-height: 1.45; font-size: 12px; user-select: text; } .buffer.empty { color: #6b7280; } .actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 10px; } .panel-button { border: 1px solid #dadce0; background: #fff; border-radius: 999px; padding: 6px 10px; cursor: pointer; font-size: 12px; } .panel-button:hover { background: #f1f3f4; } .hint { margin: 10px 0 0; font-size: 11px; color: #5f6368; line-height: 1.45; } @keyframes pulse { 0% { transform: scale(1); } 50% { transform: scale(1.06); } 100% { transform: scale(1); } } </style> <div class="wrap collapsed" id="wrap"> <div class="options" id="quickOptions"> <div class="status" id="status">מוכן</div> <select class="lang" id="languageSelect" title="שפת תמלול"></select> <button class="settings-toggle" id="settingsBtn" type="button" title="הגדרות">⚙</button> </div> <div class="mic-shell" id="micShell"> <button class="btn" id="micBtn" type="button" title="התחל תמלול"> <svg class="icon" viewBox="0 0 24 24" aria-hidden="true"> <path d="M12 15a3.75 3.75 0 0 0 3.75-3.75V6.75a3.75 3.75 0 1 0-7.5 0v4.5A3.75 3.75 0 0 0 12 15Zm6-3.75a.75.75 0 0 1 1.5 0A7.5 7.5 0 0 1 12.75 18.7V21a.75.75 0 0 1-1.5 0v-2.3A7.5 7.5 0 0 1 4.5 11.25a.75.75 0 0 1 1.5 0 6 6 0 0 0 12 0Z"></path> </svg> </button> <button class="expand-toggle" id="expandBtn" type="button" title="פתח אפשרויות" aria-expanded="false">▴</button> </div> <div class="panel" id="settingsPanel"> <p class="panel-title">תמלול קולי גלובלי</p> <div class="row"> <label for="panelLanguage">שפה</label> <select id="panelLanguage"></select> </div> <div class="row"> <label for="continuousToggle">האזנה רציפה</label> <input id="continuousToggle" type="checkbox"> </div> <div class="row"> <label for="spaceToggle">להוסיף רווח לפני התמלול</label> <input id="spaceToggle" type="checkbox"> </div> <div class="row"> <label for="capitalizeToggle">Capital באנגלית</label> <input id="capitalizeToggle" type="checkbox"> </div> <div class="row"> <label for="statusToggle">להציג סטטוס</label> <input id="statusToggle" type="checkbox"> </div> <div class="row"> <label for="langToggle">להציג בחירת שפה</label> <input id="langToggle" type="checkbox"> </div> <div class="buffer empty" id="bufferPreview">אין טקסט שמור עדיין.</div> <div class="actions"> <button class="panel-button" id="pasteBtn" type="button">הדבק לשדה הפעיל</button> <button class="panel-button" id="copyBtn" type="button">העתק</button> <button class="panel-button" id="clearBtn" type="button">נקה</button> <button class="panel-button" id="resetPositionBtn" type="button">אפס מיקום</button> </div> <div class="hint"> לחץ בתוך שדה טקסט ואז על המיקרופון. כדי להזיז את הכפתור: החזק Alt וגרור את המיקרופון. המיקום נשמר בנפרד לכל אתר. אם הכפתור יצא מהמסך, Alt+Shift+M מחזיר אותו לברירת המחדל. אם אין שדה פעיל, התמלול נשמר כאן ואפשר להעתיק/להדביק אותו אחר כך. </div> </div> </div> `; wrap = shadow.getElementById('wrap'); micButton = shadow.getElementById('micBtn'); expandButton = shadow.getElementById('expandBtn'); statusPill = shadow.getElementById('status'); languageSelect = shadow.getElementById('languageSelect'); settingsButton = shadow.getElementById('settingsBtn'); settingsPanel = shadow.getElementById('settingsPanel'); bufferPreview = shadow.getElementById('bufferPreview'); copyButton = shadow.getElementById('copyBtn'); pasteButton = shadow.getElementById('pasteBtn'); clearButton = shadow.getElementById('clearBtn'); resetPositionButton = shadow.getElementById('resetPositionBtn'); hydrateLanguageSelects(); hydratePanelControls(); bindUiEvents(); state.mounted = true; refreshUiVisibilitySettings(); updateBufferPreview(); updateButtonState(); requestAnimationFrame(applyPosition); } function setExpanded(expanded) { if (!wrap || !expandButton) return; wrap.classList.toggle('expanded', Boolean(expanded)); wrap.classList.toggle('collapsed', !expanded); expandButton.textContent = expanded ? '▾' : '▴'; expandButton.title = expanded ? 'סגור אפשרויות' : 'פתח אפשרויות'; expandButton.setAttribute('aria-expanded', expanded ? 'true' : 'false'); if (settingsPanel) settingsPanel.classList.toggle('open', Boolean(expanded)); requestAnimationFrame(applyPosition); } function toggleExpanded() { setExpanded(!wrap.classList.contains('expanded')); } function hydrateLanguageSelects() { const panelLanguage = shadow.getElementById('panelLanguage'); const optionsHtml = LANGUAGES.map((lang) => `<option value="${lang.value}">${lang.label}</option>`).join(''); languageSelect.innerHTML = optionsHtml; panelLanguage.innerHTML = optionsHtml; languageSelect.value = state.settings.language; panelLanguage.value = state.settings.language; const onLanguageChange = (value) => { saveSetting('language', value); languageSelect.value = value; panelLanguage.value = value; setStatus(`שפה: ${getLanguageLabel(value)}`); }; languageSelect.addEventListener('change', () => onLanguageChange(languageSelect.value)); panelLanguage.addEventListener('change', () => onLanguageChange(panelLanguage.value)); } function hydratePanelControls() { const continuousToggle = shadow.getElementById('continuousToggle'); const spaceToggle = shadow.getElementById('spaceToggle'); const capitalizeToggle = shadow.getElementById('capitalizeToggle'); const statusToggle = shadow.getElementById('statusToggle'); const langToggle = shadow.getElementById('langToggle'); continuousToggle.checked = Boolean(state.settings.continuous); spaceToggle.checked = Boolean(state.settings.addSpaceBeforeText); capitalizeToggle.checked = Boolean(state.settings.autoCapitalize); statusToggle.checked = Boolean(state.settings.showStatus); langToggle.checked = Boolean(state.settings.showLanguageSelect); continuousToggle.addEventListener('change', () => saveSetting('continuous', continuousToggle.checked)); spaceToggle.addEventListener('change', () => saveSetting('addSpaceBeforeText', spaceToggle.checked)); capitalizeToggle.addEventListener('change', () => saveSetting('autoCapitalize', capitalizeToggle.checked)); statusToggle.addEventListener('change', () => saveSetting('showStatus', statusToggle.checked)); langToggle.addEventListener('change', () => saveSetting('showLanguageSelect', langToggle.checked)); } function bindUiEvents() { const preventFocusLoss = (event) => { event.preventDefault(); event.stopPropagation(); }; micButton.addEventListener('pointerdown', onPointerDownForDragAndClick, true); for (const el of [expandButton, settingsButton, languageSelect, copyButton, pasteButton, clearButton, resetPositionButton]) { el.addEventListener('pointerdown', preventFocusLoss, true); } expandButton.addEventListener('click', toggleExpanded); settingsButton.addEventListener('click', () => { setExpanded(true); settingsPanel.classList.toggle('open'); requestAnimationFrame(applyPosition); }); copyButton.addEventListener('click', copyBufferToClipboard); pasteButton.addEventListener('click', pasteBufferToActiveEditor); clearButton.addEventListener('click', () => { state.bufferText = ''; updateBufferPreview(); setStatus('הטקסט השמור נמחק'); }); resetPositionButton.addEventListener('click', resetPosition); document.addEventListener('click', (event) => { const path = event.composedPath ? event.composedPath() : []; if (!path.includes(host)) { setExpanded(false); } }, true); } function onPointerDownForDragAndClick(event) { event.preventDefault(); event.stopPropagation(); const rect = wrap.getBoundingClientRect(); const path = typeof event.composedPath === 'function' ? event.composedPath() : []; const startedOnMic = event.currentTarget === micButton || path.includes(micButton); state.dragging = { active: true, moved: false, pointerId: event.pointerId, startedOnMic, canDrag: Boolean(event.altKey && startedOnMic), startX: event.clientX, startY: event.clientY, startLeft: rect.left, startTop: rect.top }; if (state.dragging.canDrag) wrap.classList.add('dragging'); try { event.currentTarget.setPointerCapture(event.pointerId); } catch (_) {} window.addEventListener('pointermove', onPointerMoveDrag, true); window.addEventListener('pointerup', onPointerUpDrag, true); window.addEventListener('pointercancel', onPointerCancelDrag, true); } function onPointerMoveDrag(event) { if (!state.dragging.active || event.pointerId !== state.dragging.pointerId) return; if (!state.dragging.canDrag) return; const dx = event.clientX - state.dragging.startX; const dy = event.clientY - state.dragging.startY; if (!state.dragging.moved && Math.hypot(dx, dy) < 6) return; state.dragging.moved = true; const left = state.dragging.startLeft + dx; const top = state.dragging.startTop + dy; wrap.style.left = `${left}px`; wrap.style.top = `${top}px`; wrap.style.right = 'auto'; wrap.style.bottom = 'auto'; } function onPointerUpDrag(event) { if (!state.dragging.active || event.pointerId !== state.dragging.pointerId) return; const wasDrag = state.dragging.moved; const rect = wrap.getBoundingClientRect(); window.removeEventListener('pointermove', onPointerMoveDrag, true); window.removeEventListener('pointerup', onPointerUpDrag, true); window.removeEventListener('pointercancel', onPointerCancelDrag, true); wrap.classList.remove('dragging'); if (wasDrag) { savePosition({ mode: 'free', left: rect.left, top: rect.top, right: null, bottom: null }); // Keep the UI quiet after dragging; the saved position is obvious visually. setStatus(state.listening ? 'מקשיב...' : 'מוכן'); } else { const path = typeof event.composedPath === 'function' ? event.composedPath() : []; const endedOnMic = event.target === micButton || micButton.contains(event.target) || path.includes(micButton); if (!state.dragging.canDrag && (state.dragging.startedOnMic || endedOnMic)) toggleListening(); } state.dragging.active = false; state.dragging.moved = false; state.dragging.startedOnMic = false; state.dragging.canDrag = false; } function onPointerCancelDrag(event) { if (!state.dragging.active || event.pointerId !== state.dragging.pointerId) return; window.removeEventListener('pointermove', onPointerMoveDrag, true); window.removeEventListener('pointerup', onPointerUpDrag, true); window.removeEventListener('pointercancel', onPointerCancelDrag, true); wrap.classList.remove('dragging'); state.dragging.active = false; state.dragging.moved = false; state.dragging.startedOnMic = false; state.dragging.canDrag = false; } function toggleListening() { if (!supportsSpeechRecognition()) { setStatus('הדפדפן לא תומך בתמלול קולי'); updateButtonState(); return; } if (state.listening) stopListening(); else startListening(); } function createRecognition() { const Recognition = getSpeechRecognitionCtor(); const recognition = new Recognition(); recognition.lang = state.settings.language || 'he-IL'; recognition.interimResults = true; recognition.continuous = Boolean(state.settings.continuous); recognition.maxAlternatives = 1; recognition.onstart = () => { state.listening = true; setStatus('מקשיב...'); updateButtonState(); }; recognition.onresult = (event) => { let finalText = ''; let interimText = ''; for (let i = event.resultIndex; i < event.results.length; i += 1) { const result = event.results[i]; const transcript = result[0]?.transcript || ''; if (result.isFinal) finalText += transcript; else interimText += transcript; } if (state.interim.editor && !interimPreviewIsIntact()) { discardCurrentSpeechSegment(); } if (state.discardUntilFinal) { if (finalText.trim()) { state.discardUntilFinal = false; state.lastTranscriptAt = Date.now(); updateBufferPreview(); setStatus('דילג על הטקסט שנמחק'); } else if (interimText.trim()) { updateBufferPreview(); setStatus('הטקסט נמחק — מדלג על המקטע'); } return; } const editor = getBestEditor(); if (editor) { state.currentEditor = editor; state.lastFocusedEditor = editor; if (finalText.trim()) { const committed = commitFinalTranscript(editor, finalText); if (committed) updateBufferPreview(finalText); state.lastTranscriptAt = Date.now(); } if (interimText.trim()) { renderInterimTranscript(editor, interimText); updateBufferPreview(interimText); setStatus(`מתמלל: ${normalizeTranscript(interimText).slice(0, 46)}`); } else if (finalText.trim()) { clearInterim({ keepVisualText: false }); setStatus('ממשיך להאזין...'); } return; } if (finalText.trim()) { appendToBuffer(finalText); state.lastTranscriptAt = Date.now(); setStatus('נשמר בחלונית'); } if (interimText.trim()) { updateBufferPreview(interimText); setStatus(`מתמלל ללא שדה: ${normalizeTranscript(interimText).slice(0, 38)}`); } }; recognition.onerror = (event) => { const code = event.error || 'unknown'; const messages = { 'not-allowed': 'אין הרשאה למיקרופון', 'service-not-allowed': 'שירות התמלול נחסם', 'audio-capture': 'לא נמצא מיקרופון', 'no-speech': 'לא זוהה דיבור', 'network': 'שגיאת רשת בתמלול', 'aborted': 'התמלול הופסק' }; if (code === 'not-allowed' || code === 'service-not-allowed') { clearInterim({ keepVisualText: true }); state.suppressNextEndRestart = true; } setStatus(messages[code] || `שגיאה: ${code}`); }; recognition.onend = () => { if (state.interim.editor && !state.discardUntilFinal) { clearInterim({ keepVisualText: true }); } else if (state.discardUntilFinal) { resetInterimState(); } const shouldRestart = state.listening && state.settings.continuous && !state.suppressNextEndRestart; if (shouldRestart) { try { recognition.start(); return; } catch (_) {} } state.listening = false; state.recognition = null; state.suppressNextEndRestart = false; state.discardUntilFinal = false; if (Date.now() - state.lastTranscriptAt > 1200 && ['מקשיב...', 'ממשיך להאזין...'].includes(state.statusText)) { setStatus('מוכן'); } updateButtonState(); }; return recognition; } function startListening() { state.currentEditor = getBestEditor(); if (state.currentEditor) state.lastFocusedEditor = state.currentEditor; state.suppressNextEndRestart = false; try { state.recognition = createRecognition(); state.recognition.start(); } catch (_) { setStatus('לא ניתן להפעיל תמלול כעת'); state.recognition = null; state.listening = false; updateButtonState(); } } function stopListening() { state.suppressNextEndRestart = true; if (state.interim.editor && !interimPreviewIsIntact()) { discardCurrentSpeechSegment(); } // Do not preserve/clear the interim text here. // When SpeechRecognition.stop() is called, Chrome may still emit a final // result after this click. Keeping the interim state alive lets the final // result replace the live preview instead of being inserted a second time. // If no final result arrives, recognition.onend will preserve the current // interim text once. if (state.recognition) { try { state.recognition.stop(); } catch (_) {} } state.listening = false; setStatus('נעצר'); updateButtonState(); } function onFocusIn(event) { const target = event.target; if (isEditableElement(target)) { state.currentEditor = target; state.lastFocusedEditor = target; if (state.listening) setStatus('מקשיב לשדה הפעיל...'); } } function onMouseDown(event) { const target = event.target; if (isEditableElement(target)) { state.currentEditor = target; state.lastFocusedEditor = target; } } function onSelectionChange() { const editor = getBestEditor(); if (editor) { state.currentEditor = editor; state.lastFocusedEditor = editor; } } function onKeyDown(event) { if (event.altKey && event.shiftKey && !event.ctrlKey && !event.metaKey && event.code === 'KeyM') { event.preventDefault(); resetPosition(); } } function registerMenuCommands() { try { if (typeof GM_registerMenuCommand !== 'function') return; GM_registerMenuCommand('Voice to Text: הפעלה/עצירה', toggleListening); GM_registerMenuCommand('Voice to Text: החלף עברית/אנגלית', () => { const next = state.settings.language === 'he-IL' ? 'en-US' : 'he-IL'; saveSetting('language', next); if (languageSelect) languageSelect.value = next; const panelLanguage = shadow?.getElementById('panelLanguage'); if (panelLanguage) panelLanguage.value = next; setStatus(`שפה: ${getLanguageLabel(next)}`); }); GM_registerMenuCommand('Voice to Text: אפס מיקום כפתור', resetPosition); } catch (_) {} } function init() { loadSettings(); ensureUi(); registerMenuCommands(); document.addEventListener('focusin', onFocusIn, true); document.addEventListener('mousedown', onMouseDown, true); document.addEventListener('selectionchange', onSelectionChange, true); document.addEventListener('keydown', onKeyDown, true); window.addEventListener('resize', () => requestAnimationFrame(applyPosition), { passive: true }); setStatus(supportsSpeechRecognition() ? 'מוכן' : 'לא נתמך בדפדפן הזה'); updateButtonState(); } function waitForBodyThenInit() { if (document.body && document.documentElement) { init(); return; } const timer = window.setInterval(() => { if (document.body && document.documentElement) { window.clearInterval(timer); init(); } }, 250); } waitForBodyThenInit(); })();אפשרות 2 - תוסף Chrome רגיל
יש גם גרסה כתוסף Google Chrome, שעובדת אותו דבר כמו הסקריפט, רק בלי צורך ב־Tampermonkey.
התקנה:
מורידים את תיקיית התוסף.
פותחים בכרום:
chrome://extensions/מפעילים Developer mode.
לוחצים על Load unpacked.
בוחרים את תיקיית התוסף.
מרעננים את האתרים הפתוחים.קובץ ה־ZIP לתוסף:
universal-voice-to-text-chrome-extension-v2.6.0.zipלא עובד בדפי מערכת של כרום כמו chrome://extensions.
איכות התמלול תלויה במיקרופון וברעש סביבתי.מי שרוצה לבדוק, לשפר או להעיר על באגים - בשמחה.
@אברהם-גלסר כתב בשיתוף | תוסף וסקריפט לתמלול דיבור לטקסט בכל אתר - ללא API:
הכלי מבוסס על מנגנון זיהוי הדיבור של הדפדפן, כך שאין צורך בשרת חיצוני או במפתח API משלכם.
-
בניתי בעזרת ChatGPT כלי קטן שמוסיף כפתור מיקרופון קבוע בדפדפן, ומאפשר להכתיב טקסט ישירות לתוך שדות כתיבה באתרים.

מה הכלי עושה בפועל:
מוסיף כפתור מיקרופון צף בכל אתר.
כברירת מחדל מוצג רק כפתור התמלול עצמו, בלי תפריטים מסביב.
יש חץ קטן לפתיחת כל האפשרויות.
בלחיצה על החץ נפתחות האפשרויות: בחירת שפה, הגדרות, טקסט שתומלל, העתקה/הדבקה וכדומה.
בלחיצה נוספת על החץ הכל נסגר שוב.בלחיצה על כפתור המיקרופון מתחיל תמלול דיבור לטקסט.
לחיצה נוספת עוצרת את התמלול.
הטקסט נכנס בזמן אמת לשדה הכתיבה הפעיל.
עובד עם textarea, input ושדות כתיבה מתקדמים יותר כמו contenteditable.
תומך בעברית, אנגלית ובכמה שפות נוספות.
יש אפשרות לבחור שפת תמלול.
אם אין שדה כתיבה פעיל, הטקסט מופיע בחלונית של הכלי ואפשר להעתיק אותו.אפשר להזיז את כפתור התמלול עם Alt + גרירה על הכפתור.
המיקום נשמר גם אחרי רענון.
המיקום נשמר בנפרד לכל אתר, כלומר אפשר לקבוע מיקום אחד ל-ChatGPT, מיקום אחר לפורום וכו׳.
ככה תוכלו להתאים את המיקום שלו לכל אתר איפה שהכי מתאים לו להיות, למשל בChatGPT:
אם הכפתור נעלם בגלל שהוזז החוצה, אפשר להחזיר אותו למיקום ברירת המחדל באתר הנוכחי עם:
Alt + Shift + M
או:
Ctrl + Alt + Mבנוסף, אם מוחקים ידנית תוך כדי תמלול טקסט שהכלי כתב בתוך תיבת הטקסט, הכלי מתעלם מהמחיקה ולא מחזיר את הטקסט הזה מחדש בסיום ההקלטה.
הכלי מבוסס על מנגנון זיהוי הדיבור של הדפדפן, כך שאין צורך בשרת חיצוני או במפתח API משלכם. בפעם הראשונה הדפדפן יבקש הרשאת מיקרופון.
יש שתי אפשרויות שימוש:
אפשרות 1 - סקריפט Tampermonkey
מתאים למי שכבר משתמש ב־Tampermonkey או רוצה להתקין userscript.
התקנה:
מתקינים Tampermonkey.
יוצרים סקריפט חדש.
מדביקים את הקוד.
שומרים.
מרעננים את האתר שבו רוצים להשתמש.הקוד לסקריפט:
// ==UserScript== // @name Universal Voice to Text - Floating Dictation Button // @namespace https://chat.openai.com/ // @version 2.6.0 // @description Floating draggable speech-to-text button for almost any website. Dictates into focused inputs, textareas and contenteditable editors. // @author Avraham + ChatGPT // @match http://*/* // @match https://*/* // @run-at document-idle // @grant GM_getValue // @grant GM_setValue // @grant GM_registerMenuCommand // ==/UserScript== (() => { 'use strict'; const APP_ID = 'uvtt-floating-root'; const STORAGE_PREFIX = 'uvtt_tm_'; const INTERIM_ATTR = 'data-uvtt-interim'; const DEFAULT_SETTINGS = { language: 'he-IL', continuous: true, autoCapitalize: true, addSpaceBeforeText: true, showStatus: true, showLanguageSelect: true }; const DEFAULT_POSITION = { mode: 'corner', right: 24, bottom: 24, left: null, top: null }; const LANGUAGES = [ { value: 'he-IL', label: 'עברית' }, { value: 'en-US', label: 'English US' }, { value: 'en-GB', label: 'English UK' }, { value: 'ar-SA', label: 'العربية' }, { value: 'fr-FR', label: 'Français' }, { value: 'es-ES', label: 'Español' }, { value: 'ru-RU', label: 'Русский' } ]; const INPUT_TYPES = new Set([ '', 'text', 'search', 'email', 'url', 'tel', 'password', 'number' ]); const state = { mounted: false, listening: false, recognition: null, settings: { ...DEFAULT_SETTINGS }, position: { ...DEFAULT_POSITION }, currentEditor: null, lastFocusedEditor: null, statusText: 'מוכן', suppressNextEndRestart: false, lastTranscriptAt: 0, bufferText: '', discardUntilFinal: false, interim: { editor: null, type: null, node: null, prefix: '', rawText: '', previewText: '', inputStart: null, inputLength: 0 }, dragging: { active: false, moved: false, pointerId: null, startedOnMic: false, canDrag: false, startX: 0, startY: 0, startLeft: 0, startTop: 0 } }; let host; let shadow; let wrap; let micButton; let expandButton; let statusPill; let languageSelect; let settingsButton; let settingsPanel; let bufferPreview; let copyButton; let pasteButton; let clearButton; let resetPositionButton; function gmGet(key, fallback) { try { if (typeof GM_getValue === 'function') { return GM_getValue(`${STORAGE_PREFIX}${key}`, fallback); } } catch (_) {} try { const raw = localStorage.getItem(`${STORAGE_PREFIX}${key}`); return raw === null ? fallback : JSON.parse(raw); } catch (_) { return fallback; } } function gmSet(key, value) { try { if (typeof GM_setValue === 'function') { GM_setValue(`${STORAGE_PREFIX}${key}`, value); return; } } catch (_) {} try { localStorage.setItem(`${STORAGE_PREFIX}${key}`, JSON.stringify(value)); } catch (_) {} } function getSiteStorageKey(name) { const origin = (() => { try { return window.location.origin || `${window.location.protocol}//${window.location.host}`; } catch (_) { return 'unknown-origin'; } })(); return `${name}:${origin}`; } function getPositionStorageKey() { return getSiteStorageKey('position'); } function loadSettings() { const loaded = { ...DEFAULT_SETTINGS }; for (const key of Object.keys(DEFAULT_SETTINGS)) { loaded[key] = gmGet(key, DEFAULT_SETTINGS[key]); } state.settings = loaded; const loadedPosition = gmGet(getPositionStorageKey(), DEFAULT_POSITION); state.position = { ...DEFAULT_POSITION, ...(loadedPosition && typeof loadedPosition === 'object' ? loadedPosition : {}) }; } function saveSetting(key, value) { state.settings[key] = value; gmSet(key, value); if (key === 'language' && state.recognition && state.listening) { setStatus('השפה תעודכן בהפעלה הבאה'); } refreshUiVisibilitySettings(); } function savePosition(position) { state.position = { ...state.position, ...position }; gmSet(getPositionStorageKey(), state.position); } function supportsSpeechRecognition() { return Boolean(window.SpeechRecognition || window.webkitSpeechRecognition); } function getSpeechRecognitionCtor() { return window.SpeechRecognition || window.webkitSpeechRecognition; } function isInsideOwnUi(node) { if (!node) return false; return node === host || (host && host.contains(node)); } function isEditableElement(element) { if (!element || !(element instanceof HTMLElement)) return false; if (isInsideOwnUi(element)) return false; if (element.isContentEditable) return true; const tag = element.tagName.toLowerCase(); if (tag === 'textarea') return !element.disabled && !element.readOnly; if (tag === 'input') { const input = element; return INPUT_TYPES.has((input.getAttribute('type') || '').toLowerCase()) && !input.disabled && !input.readOnly; } return false; } function getEditorType(editor) { if (!editor) return null; const tag = editor.tagName?.toLowerCase(); if (tag === 'textarea' || tag === 'input') return 'input'; if (editor.isContentEditable) return 'contenteditable'; return null; } function isElementVisible(el) { if (!el || !(el instanceof HTMLElement)) return false; const rect = el.getBoundingClientRect(); const style = getComputedStyle(el); return rect.width > 0 && rect.height > 0 && style.visibility !== 'hidden' && style.display !== 'none'; } function getCandidateEditors() { const selectors = [ 'textarea', 'input[type="text"]', 'input[type="search"]', 'input[type="email"]', 'input[type="url"]', 'input[type="tel"]', 'input[type="password"]', 'input[type="number"]', 'input:not([type])', '[contenteditable="true"]', '[contenteditable="plaintext-only"]', '[role="textbox"]' ]; return Array.from(document.querySelectorAll(selectors.join(','))) .filter((el) => isEditableElement(el) && isElementVisible(el)); } function getDeepActiveElement(root = document) { let active = root.activeElement; while (active && active.shadowRoot && active.shadowRoot.activeElement) { active = active.shadowRoot.activeElement; } return active; } function getBestEditor() { const active = getDeepActiveElement(); if (isEditableElement(active) && isElementVisible(active)) return active; if (state.lastFocusedEditor && isEditableElement(state.lastFocusedEditor) && isElementVisible(state.lastFocusedEditor)) { return state.lastFocusedEditor; } if (state.currentEditor && isEditableElement(state.currentEditor) && isElementVisible(state.currentEditor)) { return state.currentEditor; } const selection = window.getSelection(); if (selection && selection.anchorNode) { const candidates = getCandidateEditors(); for (const editor of candidates) { if (editor.contains(selection.anchorNode)) return editor; } } return null; } function setStatus(text) { state.statusText = text; if (statusPill) statusPill.textContent = text; } function getLanguageLabel(value) { return LANGUAGES.find((lang) => lang.value === value)?.label || value; } function normalizeTranscript(rawText) { let text = (rawText || '').replace(/\s+/g, ' ').trim(); if (!text) return ''; if (state.settings.autoCapitalize && state.settings.language.startsWith('en')) { text = text.charAt(0).toUpperCase() + text.slice(1); } return text; } function getInputPrefix(editor, start) { if (!state.settings.addSpaceBeforeText) return ''; const left = String(editor.value || '').slice(0, Math.max(0, start ?? editor.selectionStart ?? 0)); if (!left) return ''; return /[\s\n]$/.test(left) ? '' : ' '; } function editorContainsSelection(editor) { const selection = window.getSelection(); if (!selection || selection.rangeCount === 0) return false; return !!selection.anchorNode && editor.contains(selection.anchorNode); } function moveCaretToEndContentEditable(editor) { editor.focus(); const selection = window.getSelection(); if (!selection) return; const range = document.createRange(); range.selectNodeContents(editor); range.collapse(false); selection.removeAllRanges(); selection.addRange(range); } function getPlainText(editor) { return (editor.innerText || editor.textContent || '').replace(/\u00a0/g, ' '); } function getContentEditablePrefix(editor) { if (!state.settings.addSpaceBeforeText) return ''; let leftText = ''; const selection = window.getSelection(); if (selection && selection.rangeCount > 0 && editorContainsSelection(editor)) { try { const probe = selection.getRangeAt(0).cloneRange(); probe.setStart(editor, 0); leftText = probe.toString(); } catch (_) { leftText = getPlainText(editor); } } else { leftText = getPlainText(editor); } if (!leftText) return ''; return /[\s\n]$/.test(leftText) ? '' : ' '; } function dispatchInput(editor, data = '') { try { editor.dispatchEvent(new InputEvent('input', { inputType: 'insertText', data, bubbles: true, composed: true })); } catch (_) { editor.dispatchEvent(new Event('input', { bubbles: true })); } } function dispatchContentEditableDomChange(editor) { // In React/ProseMirror-style editors, sending InputEvent data for every // interim result can make the app append each partial transcript again. // We already changed the DOM ourselves, so notify the editor with a // neutral input event instead of another "insertText" payload. try { editor.dispatchEvent(new InputEvent('input', { inputType: 'insertReplacementText', data: null, bubbles: true, composed: true })); } catch (_) { editor.dispatchEvent(new Event('input', { bubbles: true })); } } function setInputRangeText(editor, replacement, start, end) { editor.focus(); try { editor.setRangeText(replacement, start, end, 'end'); } catch (_) { const value = String(editor.value || ''); editor.value = `${value.slice(0, start)}${replacement}${value.slice(end)}`; const caret = start + replacement.length; try { editor.setSelectionRange(caret, caret); } catch (_) {} } dispatchInput(editor, replacement); } function insertTextIntoInput(editor, rawText) { const normalized = normalizeTranscript(rawText); if (!normalized) return false; editor.focus(); const start = editor.selectionStart ?? String(editor.value || '').length; const end = editor.selectionEnd ?? start; const text = `${getInputPrefix(editor, start)}${normalized}`; setInputRangeText(editor, text, start, end); return true; } function getTextPositionInContentEditable(editor, charOffset) { const target = Math.max(0, Number(charOffset) || 0); const walker = document.createTreeWalker(editor, NodeFilter.SHOW_TEXT); let remaining = target; let lastPosition = null; let node; while ((node = walker.nextNode())) { const length = node.nodeValue.length; if (remaining <= length) return { node, offset: remaining }; remaining -= length; lastPosition = { node, offset: length }; } return lastPosition || { node: editor, offset: 0 }; } function selectContentEditableTextRange(editor, startOffset, endOffset) { const start = getTextPositionInContentEditable(editor, startOffset); const end = getTextPositionInContentEditable(editor, endOffset); if (!start || !end) return false; try { const range = document.createRange(); range.setStart(start.node, start.offset); range.setEnd(end.node, end.offset); const selection = window.getSelection(); if (!selection) return false; selection.removeAllRanges(); selection.addRange(range); return true; } catch (_) { return false; } } function selectLastCharactersBeforeCaret(editor, charCount) { const length = Math.max(0, Number(charCount) || 0); if (!length) return true; editor.focus(); if (!editorContainsSelection(editor)) moveCaretToEndContentEditable(editor); const selection = window.getSelection(); if (!selection || selection.rangeCount === 0) return false; try { const caret = selection.getRangeAt(0).cloneRange(); caret.collapse(false); const beforeCaret = caret.cloneRange(); beforeCaret.selectNodeContents(editor); beforeCaret.setEnd(caret.endContainer, caret.endOffset); const endOffset = beforeCaret.toString().length; const startOffset = Math.max(0, endOffset - length); return selectContentEditableTextRange(editor, startOffset, endOffset); } catch (_) { return false; } } function selectLastMatchingText(editor, text) { const needle = String(text || ''); if (!needle) return false; const haystack = getPlainText(editor); const startOffset = haystack.lastIndexOf(needle); if (startOffset < 0) return false; return selectContentEditableTextRange(editor, startOffset, startOffset + needle.length); } function replaceContentEditableSelection(editor, text) { editor.focus(); let inserted = false; try { if (document.queryCommandSupported && document.queryCommandSupported('insertText')) { inserted = document.execCommand('insertText', false, text); } } catch (_) { inserted = false; } if (!inserted) { const selection = window.getSelection(); if (!selection || selection.rangeCount === 0) return false; const range = selection.getRangeAt(0); range.deleteContents(); const textNode = document.createTextNode(text); range.insertNode(textNode); range.setStartAfter(textNode); range.setEndAfter(textNode); selection.removeAllRanges(); selection.addRange(range); dispatchContentEditableDomChange(editor); } return true; } function replaceTrackedContentEditableText(editor, oldLength, oldText, newText) { editor.focus(); const previous = String(oldText || ''); if (oldLength > 0 || previous) { // Never replace arbitrary text near the caret. If the live preview was // manually deleted/changed by the user, do not bring it back later. if (!previous || !selectLastMatchingText(editor, previous)) return false; } return replaceContentEditableSelection(editor, newText); } function insertTextIntoContentEditable(editor, rawText) { const normalized = normalizeTranscript(rawText); if (!normalized) return false; const text = `${getContentEditablePrefix(editor)}${normalized}`; editor.focus(); if (!editorContainsSelection(editor)) { moveCaretToEndContentEditable(editor); } let inserted = false; try { if (document.queryCommandSupported && document.queryCommandSupported('insertText')) { inserted = document.execCommand('insertText', false, text); } } catch (_) { inserted = false; } if (!inserted) { const selection = window.getSelection(); if (!selection || selection.rangeCount === 0) return false; const range = selection.getRangeAt(0); range.deleteContents(); const textNode = document.createTextNode(text); range.insertNode(textNode); range.setStartAfter(textNode); range.setEndAfter(textNode); selection.removeAllRanges(); selection.addRange(range); dispatchContentEditableDomChange(editor); } return true; } function insertTextIntoEditor(editor, rawText) { const type = getEditorType(editor); if (type === 'input') return insertTextIntoInput(editor, rawText); if (type === 'contenteditable') return insertTextIntoContentEditable(editor, rawText); return false; } function resetInterimState() { state.interim = { editor: null, type: null, node: null, prefix: '', rawText: '', previewText: '', inputStart: null, inputLength: 0 }; } function clearInterim({ keepVisualText = false, dispatch = true } = {}) { const interim = state.interim; if (!interim.editor) { resetInterimState(); return; } if (interim.type === 'contenteditable' && interim.node && document.contains(interim.node)) { if (keepVisualText && interim.node.textContent) { const textNode = document.createTextNode(interim.node.textContent); const parent = interim.node.parentNode; if (parent) { parent.replaceChild(textNode, interim.node); const selection = window.getSelection(); if (selection) { const range = document.createRange(); range.setStartAfter(textNode); range.collapse(true); selection.removeAllRanges(); selection.addRange(range); } } } else { interim.node.remove(); } if (dispatch) dispatchContentEditableDomChange(interim.editor); } if (interim.type === 'input' && interim.inputStart !== null && !keepVisualText) { const start = interim.inputStart; const end = start + interim.inputLength; setInputRangeText(interim.editor, '', start, end); } resetInterimState(); } function inputPreviewIsIntact(interim = state.interim) { if (!interim.editor || interim.type !== 'input' || interim.inputStart === null || interim.inputLength <= 0) return true; const value = String(interim.editor.value || ''); const expected = String(interim.previewText || ''); if (!expected) return true; const exact = value.slice(interim.inputStart, interim.inputStart + interim.inputLength) === expected; if (exact) return true; const fallbackIndex = value.lastIndexOf(expected); if (fallbackIndex >= 0) { interim.inputStart = fallbackIndex; return true; } return false; } function contentEditablePreviewIsIntact(interim = state.interim) { if (!interim.editor || interim.type !== 'contenteditable' || interim.inputLength <= 0) return true; const expected = String(interim.previewText || ''); if (!expected) return true; return getPlainText(interim.editor).includes(expected); } function interimPreviewIsIntact() { if (!state.interim.editor) return true; if (state.interim.type === 'input') return inputPreviewIsIntact(); if (state.interim.type === 'contenteditable') return contentEditablePreviewIsIntact(); return true; } function discardCurrentSpeechSegment() { state.discardUntilFinal = true; resetInterimState(); updateBufferPreview(); setStatus('הטקסט נמחק — מדלג על המקטע'); } function renderInterimInInput(editor, rawText) { const normalized = normalizeTranscript(rawText); if (!normalized) return false; editor.focus(); if (state.interim.editor !== editor || state.interim.type !== 'input') { clearInterim({ keepVisualText: true }); const start = editor.selectionStart ?? String(editor.value || '').length; state.interim.editor = editor; state.interim.type = 'input'; state.interim.inputStart = start; state.interim.prefix = getInputPrefix(editor, start); state.interim.inputLength = 0; state.interim.previewText = ''; } else if (!inputPreviewIsIntact()) { discardCurrentSpeechSegment(); return false; } const text = `${state.interim.prefix}${normalized}`; const start = state.interim.inputStart; const end = start + state.interim.inputLength; setInputRangeText(editor, text, start, end); state.interim.inputLength = text.length; state.interim.previewText = text; state.interim.rawText = rawText; return true; } function ensureInterimNode(editor) { if (state.interim.editor === editor && state.interim.type === 'contenteditable' && state.interim.node && document.contains(state.interim.node)) { return state.interim.node; } clearInterim({ keepVisualText: true }); editor.focus(); if (!editorContainsSelection(editor)) { moveCaretToEndContentEditable(editor); } const selection = window.getSelection(); if (!selection || selection.rangeCount === 0) return null; const span = document.createElement('span'); span.setAttribute(INTERIM_ATTR, '1'); span.style.opacity = '0.72'; span.style.whiteSpace = 'pre-wrap'; span.style.borderBottom = '1px dotted currentColor'; state.interim.editor = editor; state.interim.type = 'contenteditable'; state.interim.node = span; state.interim.prefix = getContentEditablePrefix(editor); state.interim.rawText = ''; span.textContent = state.interim.prefix; const range = selection.getRangeAt(0); range.deleteContents(); range.insertNode(span); const after = document.createRange(); after.setStartAfter(span); after.collapse(true); selection.removeAllRanges(); selection.addRange(after); return span; } function renderInterimInContentEditable(editor, rawText) { const normalized = normalizeTranscript(rawText); if (!normalized) return false; if (state.interim.editor !== editor || state.interim.type !== 'contenteditable') { clearInterim({ keepVisualText: true }); editor.focus(); if (!editorContainsSelection(editor)) moveCaretToEndContentEditable(editor); state.interim.editor = editor; state.interim.type = 'contenteditable'; state.interim.node = null; state.interim.prefix = getContentEditablePrefix(editor); state.interim.inputLength = 0; state.interim.previewText = ''; state.interim.rawText = ''; } const text = `${state.interim.prefix}${normalized}`; const ok = replaceTrackedContentEditableText( editor, state.interim.inputLength, state.interim.previewText, text ); if (!ok) { discardCurrentSpeechSegment(); return false; } state.interim.inputLength = text.length; state.interim.previewText = text; state.interim.rawText = rawText; return true; } function renderInterimTranscript(editor, rawText) { const type = getEditorType(editor); if (type === 'input') return renderInterimInInput(editor, rawText); if (type === 'contenteditable') return renderInterimInContentEditable(editor, rawText); return false; } function commitFinalToInput(editor, rawText) { const normalized = normalizeTranscript(rawText); if (!normalized) return false; if (state.interim.editor === editor && state.interim.type === 'input' && state.interim.inputStart !== null) { if (!inputPreviewIsIntact()) { resetInterimState(); setStatus('דילג על הטקסט שנמחק'); return false; } const text = `${state.interim.prefix}${normalized}`; const start = state.interim.inputStart; const end = start + state.interim.inputLength; setInputRangeText(editor, text, start, end); resetInterimState(); return true; } return insertTextIntoInput(editor, normalized); } function commitFinalToContentEditable(editor, rawText) { const normalized = normalizeTranscript(rawText); if (!normalized) return false; if (state.interim.editor === editor && state.interim.type === 'contenteditable') { const finalText = `${state.interim.prefix}${normalized}`; const ok = replaceTrackedContentEditableText( editor, state.interim.inputLength, state.interim.previewText, finalText ); resetInterimState(); if (!ok) setStatus('דילג על הטקסט שנמחק'); return ok; } return insertTextIntoContentEditable(editor, normalized); } function commitFinalTranscript(editor, rawText) { const type = getEditorType(editor); if (type === 'input') return commitFinalToInput(editor, rawText); if (type === 'contenteditable') return commitFinalToContentEditable(editor, rawText); return false; } function appendToBuffer(rawText) { const normalized = normalizeTranscript(rawText); if (!normalized) return; const prefix = state.bufferText && !/[\s\n]$/.test(state.bufferText) ? ' ' : ''; state.bufferText = `${state.bufferText}${prefix}${normalized}`; updateBufferPreview(); } function updateBufferPreview(interimText = '') { if (!bufferPreview) return; const interim = normalizeTranscript(interimText); const full = `${state.bufferText}${interim ? `${state.bufferText ? ' ' : ''}${interim}` : ''}`.trim(); bufferPreview.textContent = full || 'אין טקסט שמור עדיין.'; bufferPreview.classList.toggle('empty', !full); } async function copyBufferToClipboard() { const text = state.bufferText.trim(); if (!text) { setStatus('אין טקסט להעתקה'); return; } try { await navigator.clipboard.writeText(text); setStatus('הטקסט הועתק'); } catch (_) { setStatus('לא ניתן להעתיק אוטומטית'); } } function pasteBufferToActiveEditor() { const text = state.bufferText.trim(); if (!text) { setStatus('אין טקסט להדבקה'); return; } const editor = getBestEditor(); if (!editor) { setStatus('בחר קודם שדה טקסט בדף'); return; } if (insertTextIntoEditor(editor, text)) { state.bufferText = ''; updateBufferPreview(); setStatus('הטקסט הודבק לשדה הפעיל'); } } function updateButtonState() { if (!micButton) return; const supported = supportsSpeechRecognition(); micButton.classList.toggle('recording', state.listening); micButton.classList.toggle('unsupported', !supported); micButton.title = supported ? (state.listening ? 'עצור תמלול · Alt+גרירה להזזה' : 'התחל תמלול · Alt+גרירה להזזה') : 'הדפדפן לא תומך בתמלול קולי'; micButton.setAttribute('aria-pressed', state.listening ? 'true' : 'false'); if (!supported) { setStatus('לא נתמך בדפדפן הזה'); } } function refreshUiVisibilitySettings() { if (!wrap) return; wrap.classList.toggle('hide-status', !state.settings.showStatus); wrap.classList.toggle('hide-lang', !state.settings.showLanguageSelect); } function applyPosition() { if (!wrap) return; if (state.position.mode === 'free' && Number.isFinite(state.position.left) && Number.isFinite(state.position.top)) { // Intentionally do not clamp to the viewport. Users can move the button // partially or fully outside the visible page, and restore it with // Alt+Shift+M or the reset button when the panel is visible. wrap.style.left = `${state.position.left}px`; wrap.style.top = `${state.position.top}px`; wrap.style.right = 'auto'; wrap.style.bottom = 'auto'; return; } wrap.style.left = 'auto'; wrap.style.top = 'auto'; wrap.style.right = `${state.position.right ?? 24}px`; wrap.style.bottom = `${state.position.bottom ?? 24}px`; } function resetPosition() { savePosition({ ...DEFAULT_POSITION }); requestAnimationFrame(applyPosition); setStatus('המיקום אופס לאתר הזה'); } function ensureUi() { if (state.mounted) return; host = document.createElement('div'); host.id = APP_ID; host.setAttribute('aria-hidden', 'false'); document.documentElement.appendChild(host); shadow = host.attachShadow({ mode: 'open' }); shadow.innerHTML = ` <style> :host { all: initial; } .wrap { position: fixed; z-index: 2147483647; display: inline-flex; align-items: center; gap: 8px; direction: rtl; font-family: Arial, sans-serif; user-select: none; touch-action: none; } .options { display: inline-flex; align-items: center; gap: 8px; } .wrap.collapsed .options, .wrap.collapsed .panel { display: none !important; } .status, .lang, .settings-toggle, .expand-toggle { background: rgba(17, 24, 39, 0.94); color: #fff; border-radius: 999px; box-shadow: 0 8px 20px rgba(0,0,0,0.18); } .status { padding: 7px 11px; font-size: 12px; line-height: 1; white-space: nowrap; max-width: min(280px, 40vw); overflow: hidden; text-overflow: ellipsis; } .hide-status .status { display: none; } .hide-lang .lang { display: none; } .lang { border: 0; outline: 0; padding: 7px 10px; font-size: 12px; cursor: pointer; max-width: 125px; } .mic-shell { position: relative; width: 54px; height: 54px; display: inline-flex; align-items: center; justify-content: center; flex: 0 0 auto; } .btn, .settings-toggle, .expand-toggle { border: none; cursor: pointer; display: inline-flex; align-items: center; justify-content: center; transition: transform 120ms ease, box-shadow 120ms ease, opacity 120ms ease, background 120ms ease; } .btn { width: 54px; height: 54px; border-radius: 999px; background: #1a73e8; color: #fff; box-shadow: 0 12px 26px rgba(26, 115, 232, 0.34); position: relative; } .btn:hover, .settings-toggle:hover, .expand-toggle:hover { transform: translateY(-1px); } .btn:active, .settings-toggle:active, .expand-toggle:active { transform: translateY(0); } .btn.recording { background: #d93025; box-shadow: 0 12px 26px rgba(217, 48, 37, 0.38); animation: pulse 1.2s infinite; } .btn.unsupported { cursor: not-allowed; opacity: 0.65; box-shadow: none; animation: none; } .wrap.dragging .btn { cursor: grabbing; } .settings-toggle { width: 34px; height: 34px; font-size: 16px; } .expand-toggle { position: absolute; left: -3px; bottom: -3px; z-index: 2; width: 22px; height: 22px; padding: 0; font-size: 12px; font-weight: 700; line-height: 1; box-shadow: 0 6px 14px rgba(0,0,0,0.22); } .wrap.expanded .expand-toggle { background: rgba(17, 24, 39, 0.98); } .icon { width: 26px; height: 26px; fill: currentColor; pointer-events: none; } .panel { position: absolute; right: 0; bottom: 66px; display: none; width: min(320px, calc(100vw - 24px)); padding: 12px; border-radius: 16px; background: rgba(255,255,255,0.99); color: #202124; box-shadow: 0 16px 42px rgba(0,0,0,0.24); border: 1px solid rgba(0,0,0,0.08); } .panel.open { display: block; } .panel-title { font-weight: 700; margin: 0 0 10px; font-size: 14px; } .row { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin: 9px 0; font-size: 13px; } .row label { cursor: pointer; } .panel select { max-width: 150px; border: 1px solid #dadce0; border-radius: 8px; padding: 5px 7px; background: #fff; } .buffer { margin-top: 10px; padding: 9px; border: 1px solid #e0e0e0; border-radius: 12px; background: #f8fafd; max-height: 110px; overflow: auto; white-space: pre-wrap; line-height: 1.45; font-size: 12px; user-select: text; } .buffer.empty { color: #6b7280; } .actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 10px; } .panel-button { border: 1px solid #dadce0; background: #fff; border-radius: 999px; padding: 6px 10px; cursor: pointer; font-size: 12px; } .panel-button:hover { background: #f1f3f4; } .hint { margin: 10px 0 0; font-size: 11px; color: #5f6368; line-height: 1.45; } @keyframes pulse { 0% { transform: scale(1); } 50% { transform: scale(1.06); } 100% { transform: scale(1); } } </style> <div class="wrap collapsed" id="wrap"> <div class="options" id="quickOptions"> <div class="status" id="status">מוכן</div> <select class="lang" id="languageSelect" title="שפת תמלול"></select> <button class="settings-toggle" id="settingsBtn" type="button" title="הגדרות">⚙</button> </div> <div class="mic-shell" id="micShell"> <button class="btn" id="micBtn" type="button" title="התחל תמלול"> <svg class="icon" viewBox="0 0 24 24" aria-hidden="true"> <path d="M12 15a3.75 3.75 0 0 0 3.75-3.75V6.75a3.75 3.75 0 1 0-7.5 0v4.5A3.75 3.75 0 0 0 12 15Zm6-3.75a.75.75 0 0 1 1.5 0A7.5 7.5 0 0 1 12.75 18.7V21a.75.75 0 0 1-1.5 0v-2.3A7.5 7.5 0 0 1 4.5 11.25a.75.75 0 0 1 1.5 0 6 6 0 0 0 12 0Z"></path> </svg> </button> <button class="expand-toggle" id="expandBtn" type="button" title="פתח אפשרויות" aria-expanded="false">▴</button> </div> <div class="panel" id="settingsPanel"> <p class="panel-title">תמלול קולי גלובלי</p> <div class="row"> <label for="panelLanguage">שפה</label> <select id="panelLanguage"></select> </div> <div class="row"> <label for="continuousToggle">האזנה רציפה</label> <input id="continuousToggle" type="checkbox"> </div> <div class="row"> <label for="spaceToggle">להוסיף רווח לפני התמלול</label> <input id="spaceToggle" type="checkbox"> </div> <div class="row"> <label for="capitalizeToggle">Capital באנגלית</label> <input id="capitalizeToggle" type="checkbox"> </div> <div class="row"> <label for="statusToggle">להציג סטטוס</label> <input id="statusToggle" type="checkbox"> </div> <div class="row"> <label for="langToggle">להציג בחירת שפה</label> <input id="langToggle" type="checkbox"> </div> <div class="buffer empty" id="bufferPreview">אין טקסט שמור עדיין.</div> <div class="actions"> <button class="panel-button" id="pasteBtn" type="button">הדבק לשדה הפעיל</button> <button class="panel-button" id="copyBtn" type="button">העתק</button> <button class="panel-button" id="clearBtn" type="button">נקה</button> <button class="panel-button" id="resetPositionBtn" type="button">אפס מיקום</button> </div> <div class="hint"> לחץ בתוך שדה טקסט ואז על המיקרופון. כדי להזיז את הכפתור: החזק Alt וגרור את המיקרופון. המיקום נשמר בנפרד לכל אתר. אם הכפתור יצא מהמסך, Alt+Shift+M מחזיר אותו לברירת המחדל. אם אין שדה פעיל, התמלול נשמר כאן ואפשר להעתיק/להדביק אותו אחר כך. </div> </div> </div> `; wrap = shadow.getElementById('wrap'); micButton = shadow.getElementById('micBtn'); expandButton = shadow.getElementById('expandBtn'); statusPill = shadow.getElementById('status'); languageSelect = shadow.getElementById('languageSelect'); settingsButton = shadow.getElementById('settingsBtn'); settingsPanel = shadow.getElementById('settingsPanel'); bufferPreview = shadow.getElementById('bufferPreview'); copyButton = shadow.getElementById('copyBtn'); pasteButton = shadow.getElementById('pasteBtn'); clearButton = shadow.getElementById('clearBtn'); resetPositionButton = shadow.getElementById('resetPositionBtn'); hydrateLanguageSelects(); hydratePanelControls(); bindUiEvents(); state.mounted = true; refreshUiVisibilitySettings(); updateBufferPreview(); updateButtonState(); requestAnimationFrame(applyPosition); } function setExpanded(expanded) { if (!wrap || !expandButton) return; wrap.classList.toggle('expanded', Boolean(expanded)); wrap.classList.toggle('collapsed', !expanded); expandButton.textContent = expanded ? '▾' : '▴'; expandButton.title = expanded ? 'סגור אפשרויות' : 'פתח אפשרויות'; expandButton.setAttribute('aria-expanded', expanded ? 'true' : 'false'); if (settingsPanel) settingsPanel.classList.toggle('open', Boolean(expanded)); requestAnimationFrame(applyPosition); } function toggleExpanded() { setExpanded(!wrap.classList.contains('expanded')); } function hydrateLanguageSelects() { const panelLanguage = shadow.getElementById('panelLanguage'); const optionsHtml = LANGUAGES.map((lang) => `<option value="${lang.value}">${lang.label}</option>`).join(''); languageSelect.innerHTML = optionsHtml; panelLanguage.innerHTML = optionsHtml; languageSelect.value = state.settings.language; panelLanguage.value = state.settings.language; const onLanguageChange = (value) => { saveSetting('language', value); languageSelect.value = value; panelLanguage.value = value; setStatus(`שפה: ${getLanguageLabel(value)}`); }; languageSelect.addEventListener('change', () => onLanguageChange(languageSelect.value)); panelLanguage.addEventListener('change', () => onLanguageChange(panelLanguage.value)); } function hydratePanelControls() { const continuousToggle = shadow.getElementById('continuousToggle'); const spaceToggle = shadow.getElementById('spaceToggle'); const capitalizeToggle = shadow.getElementById('capitalizeToggle'); const statusToggle = shadow.getElementById('statusToggle'); const langToggle = shadow.getElementById('langToggle'); continuousToggle.checked = Boolean(state.settings.continuous); spaceToggle.checked = Boolean(state.settings.addSpaceBeforeText); capitalizeToggle.checked = Boolean(state.settings.autoCapitalize); statusToggle.checked = Boolean(state.settings.showStatus); langToggle.checked = Boolean(state.settings.showLanguageSelect); continuousToggle.addEventListener('change', () => saveSetting('continuous', continuousToggle.checked)); spaceToggle.addEventListener('change', () => saveSetting('addSpaceBeforeText', spaceToggle.checked)); capitalizeToggle.addEventListener('change', () => saveSetting('autoCapitalize', capitalizeToggle.checked)); statusToggle.addEventListener('change', () => saveSetting('showStatus', statusToggle.checked)); langToggle.addEventListener('change', () => saveSetting('showLanguageSelect', langToggle.checked)); } function bindUiEvents() { const preventFocusLoss = (event) => { event.preventDefault(); event.stopPropagation(); }; micButton.addEventListener('pointerdown', onPointerDownForDragAndClick, true); for (const el of [expandButton, settingsButton, languageSelect, copyButton, pasteButton, clearButton, resetPositionButton]) { el.addEventListener('pointerdown', preventFocusLoss, true); } expandButton.addEventListener('click', toggleExpanded); settingsButton.addEventListener('click', () => { setExpanded(true); settingsPanel.classList.toggle('open'); requestAnimationFrame(applyPosition); }); copyButton.addEventListener('click', copyBufferToClipboard); pasteButton.addEventListener('click', pasteBufferToActiveEditor); clearButton.addEventListener('click', () => { state.bufferText = ''; updateBufferPreview(); setStatus('הטקסט השמור נמחק'); }); resetPositionButton.addEventListener('click', resetPosition); document.addEventListener('click', (event) => { const path = event.composedPath ? event.composedPath() : []; if (!path.includes(host)) { setExpanded(false); } }, true); } function onPointerDownForDragAndClick(event) { event.preventDefault(); event.stopPropagation(); const rect = wrap.getBoundingClientRect(); const path = typeof event.composedPath === 'function' ? event.composedPath() : []; const startedOnMic = event.currentTarget === micButton || path.includes(micButton); state.dragging = { active: true, moved: false, pointerId: event.pointerId, startedOnMic, canDrag: Boolean(event.altKey && startedOnMic), startX: event.clientX, startY: event.clientY, startLeft: rect.left, startTop: rect.top }; if (state.dragging.canDrag) wrap.classList.add('dragging'); try { event.currentTarget.setPointerCapture(event.pointerId); } catch (_) {} window.addEventListener('pointermove', onPointerMoveDrag, true); window.addEventListener('pointerup', onPointerUpDrag, true); window.addEventListener('pointercancel', onPointerCancelDrag, true); } function onPointerMoveDrag(event) { if (!state.dragging.active || event.pointerId !== state.dragging.pointerId) return; if (!state.dragging.canDrag) return; const dx = event.clientX - state.dragging.startX; const dy = event.clientY - state.dragging.startY; if (!state.dragging.moved && Math.hypot(dx, dy) < 6) return; state.dragging.moved = true; const left = state.dragging.startLeft + dx; const top = state.dragging.startTop + dy; wrap.style.left = `${left}px`; wrap.style.top = `${top}px`; wrap.style.right = 'auto'; wrap.style.bottom = 'auto'; } function onPointerUpDrag(event) { if (!state.dragging.active || event.pointerId !== state.dragging.pointerId) return; const wasDrag = state.dragging.moved; const rect = wrap.getBoundingClientRect(); window.removeEventListener('pointermove', onPointerMoveDrag, true); window.removeEventListener('pointerup', onPointerUpDrag, true); window.removeEventListener('pointercancel', onPointerCancelDrag, true); wrap.classList.remove('dragging'); if (wasDrag) { savePosition({ mode: 'free', left: rect.left, top: rect.top, right: null, bottom: null }); // Keep the UI quiet after dragging; the saved position is obvious visually. setStatus(state.listening ? 'מקשיב...' : 'מוכן'); } else { const path = typeof event.composedPath === 'function' ? event.composedPath() : []; const endedOnMic = event.target === micButton || micButton.contains(event.target) || path.includes(micButton); if (!state.dragging.canDrag && (state.dragging.startedOnMic || endedOnMic)) toggleListening(); } state.dragging.active = false; state.dragging.moved = false; state.dragging.startedOnMic = false; state.dragging.canDrag = false; } function onPointerCancelDrag(event) { if (!state.dragging.active || event.pointerId !== state.dragging.pointerId) return; window.removeEventListener('pointermove', onPointerMoveDrag, true); window.removeEventListener('pointerup', onPointerUpDrag, true); window.removeEventListener('pointercancel', onPointerCancelDrag, true); wrap.classList.remove('dragging'); state.dragging.active = false; state.dragging.moved = false; state.dragging.startedOnMic = false; state.dragging.canDrag = false; } function toggleListening() { if (!supportsSpeechRecognition()) { setStatus('הדפדפן לא תומך בתמלול קולי'); updateButtonState(); return; } if (state.listening) stopListening(); else startListening(); } function createRecognition() { const Recognition = getSpeechRecognitionCtor(); const recognition = new Recognition(); recognition.lang = state.settings.language || 'he-IL'; recognition.interimResults = true; recognition.continuous = Boolean(state.settings.continuous); recognition.maxAlternatives = 1; recognition.onstart = () => { state.listening = true; setStatus('מקשיב...'); updateButtonState(); }; recognition.onresult = (event) => { let finalText = ''; let interimText = ''; for (let i = event.resultIndex; i < event.results.length; i += 1) { const result = event.results[i]; const transcript = result[0]?.transcript || ''; if (result.isFinal) finalText += transcript; else interimText += transcript; } if (state.interim.editor && !interimPreviewIsIntact()) { discardCurrentSpeechSegment(); } if (state.discardUntilFinal) { if (finalText.trim()) { state.discardUntilFinal = false; state.lastTranscriptAt = Date.now(); updateBufferPreview(); setStatus('דילג על הטקסט שנמחק'); } else if (interimText.trim()) { updateBufferPreview(); setStatus('הטקסט נמחק — מדלג על המקטע'); } return; } const editor = getBestEditor(); if (editor) { state.currentEditor = editor; state.lastFocusedEditor = editor; if (finalText.trim()) { const committed = commitFinalTranscript(editor, finalText); if (committed) updateBufferPreview(finalText); state.lastTranscriptAt = Date.now(); } if (interimText.trim()) { renderInterimTranscript(editor, interimText); updateBufferPreview(interimText); setStatus(`מתמלל: ${normalizeTranscript(interimText).slice(0, 46)}`); } else if (finalText.trim()) { clearInterim({ keepVisualText: false }); setStatus('ממשיך להאזין...'); } return; } if (finalText.trim()) { appendToBuffer(finalText); state.lastTranscriptAt = Date.now(); setStatus('נשמר בחלונית'); } if (interimText.trim()) { updateBufferPreview(interimText); setStatus(`מתמלל ללא שדה: ${normalizeTranscript(interimText).slice(0, 38)}`); } }; recognition.onerror = (event) => { const code = event.error || 'unknown'; const messages = { 'not-allowed': 'אין הרשאה למיקרופון', 'service-not-allowed': 'שירות התמלול נחסם', 'audio-capture': 'לא נמצא מיקרופון', 'no-speech': 'לא זוהה דיבור', 'network': 'שגיאת רשת בתמלול', 'aborted': 'התמלול הופסק' }; if (code === 'not-allowed' || code === 'service-not-allowed') { clearInterim({ keepVisualText: true }); state.suppressNextEndRestart = true; } setStatus(messages[code] || `שגיאה: ${code}`); }; recognition.onend = () => { if (state.interim.editor && !state.discardUntilFinal) { clearInterim({ keepVisualText: true }); } else if (state.discardUntilFinal) { resetInterimState(); } const shouldRestart = state.listening && state.settings.continuous && !state.suppressNextEndRestart; if (shouldRestart) { try { recognition.start(); return; } catch (_) {} } state.listening = false; state.recognition = null; state.suppressNextEndRestart = false; state.discardUntilFinal = false; if (Date.now() - state.lastTranscriptAt > 1200 && ['מקשיב...', 'ממשיך להאזין...'].includes(state.statusText)) { setStatus('מוכן'); } updateButtonState(); }; return recognition; } function startListening() { state.currentEditor = getBestEditor(); if (state.currentEditor) state.lastFocusedEditor = state.currentEditor; state.suppressNextEndRestart = false; try { state.recognition = createRecognition(); state.recognition.start(); } catch (_) { setStatus('לא ניתן להפעיל תמלול כעת'); state.recognition = null; state.listening = false; updateButtonState(); } } function stopListening() { state.suppressNextEndRestart = true; if (state.interim.editor && !interimPreviewIsIntact()) { discardCurrentSpeechSegment(); } // Do not preserve/clear the interim text here. // When SpeechRecognition.stop() is called, Chrome may still emit a final // result after this click. Keeping the interim state alive lets the final // result replace the live preview instead of being inserted a second time. // If no final result arrives, recognition.onend will preserve the current // interim text once. if (state.recognition) { try { state.recognition.stop(); } catch (_) {} } state.listening = false; setStatus('נעצר'); updateButtonState(); } function onFocusIn(event) { const target = event.target; if (isEditableElement(target)) { state.currentEditor = target; state.lastFocusedEditor = target; if (state.listening) setStatus('מקשיב לשדה הפעיל...'); } } function onMouseDown(event) { const target = event.target; if (isEditableElement(target)) { state.currentEditor = target; state.lastFocusedEditor = target; } } function onSelectionChange() { const editor = getBestEditor(); if (editor) { state.currentEditor = editor; state.lastFocusedEditor = editor; } } function onKeyDown(event) { if (event.altKey && event.shiftKey && !event.ctrlKey && !event.metaKey && event.code === 'KeyM') { event.preventDefault(); resetPosition(); } } function registerMenuCommands() { try { if (typeof GM_registerMenuCommand !== 'function') return; GM_registerMenuCommand('Voice to Text: הפעלה/עצירה', toggleListening); GM_registerMenuCommand('Voice to Text: החלף עברית/אנגלית', () => { const next = state.settings.language === 'he-IL' ? 'en-US' : 'he-IL'; saveSetting('language', next); if (languageSelect) languageSelect.value = next; const panelLanguage = shadow?.getElementById('panelLanguage'); if (panelLanguage) panelLanguage.value = next; setStatus(`שפה: ${getLanguageLabel(next)}`); }); GM_registerMenuCommand('Voice to Text: אפס מיקום כפתור', resetPosition); } catch (_) {} } function init() { loadSettings(); ensureUi(); registerMenuCommands(); document.addEventListener('focusin', onFocusIn, true); document.addEventListener('mousedown', onMouseDown, true); document.addEventListener('selectionchange', onSelectionChange, true); document.addEventListener('keydown', onKeyDown, true); window.addEventListener('resize', () => requestAnimationFrame(applyPosition), { passive: true }); setStatus(supportsSpeechRecognition() ? 'מוכן' : 'לא נתמך בדפדפן הזה'); updateButtonState(); } function waitForBodyThenInit() { if (document.body && document.documentElement) { init(); return; } const timer = window.setInterval(() => { if (document.body && document.documentElement) { window.clearInterval(timer); init(); } }, 250); } waitForBodyThenInit(); })();אפשרות 2 - תוסף Chrome רגיל
יש גם גרסה כתוסף Google Chrome, שעובדת אותו דבר כמו הסקריפט, רק בלי צורך ב־Tampermonkey.
התקנה:
מורידים את תיקיית התוסף.
פותחים בכרום:
chrome://extensions/מפעילים Developer mode.
לוחצים על Load unpacked.
בוחרים את תיקיית התוסף.
מרעננים את האתרים הפתוחים.קובץ ה־ZIP לתוסף:
universal-voice-to-text-chrome-extension-v2.6.0.zipלא עובד בדפי מערכת של כרום כמו chrome://extensions.
איכות התמלול תלויה במיקרופון וברעש סביבתי.מי שרוצה לבדוק, לשפר או להעיר על באגים - בשמחה.
@אברהם-גלסר
יפה מאד@אברהם-גלסר כתב בשיתוף | תוסף וסקריפט לתמלול דיבור לטקסט בכל אתר - ללא API:
אפשרות 1 - סקריפט Tampermonkey
זה אמור לעבוד גם בגלישה בסתר?
-
@אברהם-גלסר יפה מאוד.
חשוב לציין שאין סיבה שיעבוד עם api.
למיטב הבנתי [לא היה לי כח לקרוא הכל] זה משתמש במיקרופון המובנה.. -
@אברהם-גלסר
יפה מאד@אברהם-גלסר כתב בשיתוף | תוסף וסקריפט לתמלול דיבור לטקסט בכל אתר - ללא API:
אפשרות 1 - סקריפט Tampermonkey
זה אמור לעבוד גם בגלישה בסתר?
-
@אברהם-גלסר
יפה מאד@אברהם-גלסר כתב בשיתוף | תוסף וסקריפט לתמלול דיבור לטקסט בכל אתר - ללא API:
אפשרות 1 - סקריפט Tampermonkey
זה אמור לעבוד גם בגלישה בסתר?
@3157686 לכאורה אם אתה מפעיל את Tampermonky בגלישה בסתר, ב"ניהול התוסף" (וכמו כן גם התוסף)

-
@3157686 כתב בשיתוף | תוסף וסקריפט לתמלול דיבור לטקסט בכל אתר - ללא API:
זה אמור לעבוד גם בגלישה בסתר?
בעיקרון תוספים בברירת מחדל מושבתים בגלישה בסתר, אמור להיות אפשרות להחריג תוסף ספציפי שיעבוד גם שם
@ע-ה-דכו-ע
לכן שאלתי על האפשרות הראשונה -
@ע-ה-דכו-ע
לכן שאלתי על האפשרות הראשונה@3157686 כתב בשיתוף | תוסף וסקריפט לתמלול דיבור לטקסט בכל אתר - ללא API:
@ע-ה-דכו-ע
לכן שאלתי על האפשרות הראשונההאפשרות הראשונה היא גם תוסף, רק תוסף אחד שמפעיל הרבה סקריפטים, אם מפעילים אותו בגלישה בסתר אז לכאו' כל הסקריפטים אמורים לעבוד
-
בניתי בעזרת ChatGPT כלי קטן שמוסיף כפתור מיקרופון קבוע בדפדפן, ומאפשר להכתיב טקסט ישירות לתוך שדות כתיבה באתרים.

מה הכלי עושה בפועל:
מוסיף כפתור מיקרופון צף בכל אתר.
כברירת מחדל מוצג רק כפתור התמלול עצמו, בלי תפריטים מסביב.
יש חץ קטן לפתיחת כל האפשרויות.
בלחיצה על החץ נפתחות האפשרויות: בחירת שפה, הגדרות, טקסט שתומלל, העתקה/הדבקה וכדומה.
בלחיצה נוספת על החץ הכל נסגר שוב.בלחיצה על כפתור המיקרופון מתחיל תמלול דיבור לטקסט.
לחיצה נוספת עוצרת את התמלול.
הטקסט נכנס בזמן אמת לשדה הכתיבה הפעיל.
עובד עם textarea, input ושדות כתיבה מתקדמים יותר כמו contenteditable.
תומך בעברית, אנגלית ובכמה שפות נוספות.
יש אפשרות לבחור שפת תמלול.
אם אין שדה כתיבה פעיל, הטקסט מופיע בחלונית של הכלי ואפשר להעתיק אותו.אפשר להזיז את כפתור התמלול עם Alt + גרירה על הכפתור.
המיקום נשמר גם אחרי רענון.
המיקום נשמר בנפרד לכל אתר, כלומר אפשר לקבוע מיקום אחד ל-ChatGPT, מיקום אחר לפורום וכו׳.
ככה תוכלו להתאים את המיקום שלו לכל אתר איפה שהכי מתאים לו להיות, למשל בChatGPT:
אם הכפתור נעלם בגלל שהוזז החוצה, אפשר להחזיר אותו למיקום ברירת המחדל באתר הנוכחי עם:
Alt + Shift + M
או:
Ctrl + Alt + Mבנוסף, אם מוחקים ידנית תוך כדי תמלול טקסט שהכלי כתב בתוך תיבת הטקסט, הכלי מתעלם מהמחיקה ולא מחזיר את הטקסט הזה מחדש בסיום ההקלטה.
הכלי מבוסס על מנגנון זיהוי הדיבור של הדפדפן, כך שאין צורך בשרת חיצוני או במפתח API משלכם. בפעם הראשונה הדפדפן יבקש הרשאת מיקרופון.
יש שתי אפשרויות שימוש:
אפשרות 1 - סקריפט Tampermonkey
מתאים למי שכבר משתמש ב־Tampermonkey או רוצה להתקין userscript.
התקנה:
מתקינים Tampermonkey.
יוצרים סקריפט חדש.
מדביקים את הקוד.
שומרים.
מרעננים את האתר שבו רוצים להשתמש.הקוד לסקריפט:
// ==UserScript== // @name Universal Voice to Text - Floating Dictation Button // @namespace https://chat.openai.com/ // @version 2.6.0 // @description Floating draggable speech-to-text button for almost any website. Dictates into focused inputs, textareas and contenteditable editors. // @author Avraham + ChatGPT // @match http://*/* // @match https://*/* // @run-at document-idle // @grant GM_getValue // @grant GM_setValue // @grant GM_registerMenuCommand // ==/UserScript== (() => { 'use strict'; const APP_ID = 'uvtt-floating-root'; const STORAGE_PREFIX = 'uvtt_tm_'; const INTERIM_ATTR = 'data-uvtt-interim'; const DEFAULT_SETTINGS = { language: 'he-IL', continuous: true, autoCapitalize: true, addSpaceBeforeText: true, showStatus: true, showLanguageSelect: true }; const DEFAULT_POSITION = { mode: 'corner', right: 24, bottom: 24, left: null, top: null }; const LANGUAGES = [ { value: 'he-IL', label: 'עברית' }, { value: 'en-US', label: 'English US' }, { value: 'en-GB', label: 'English UK' }, { value: 'ar-SA', label: 'العربية' }, { value: 'fr-FR', label: 'Français' }, { value: 'es-ES', label: 'Español' }, { value: 'ru-RU', label: 'Русский' } ]; const INPUT_TYPES = new Set([ '', 'text', 'search', 'email', 'url', 'tel', 'password', 'number' ]); const state = { mounted: false, listening: false, recognition: null, settings: { ...DEFAULT_SETTINGS }, position: { ...DEFAULT_POSITION }, currentEditor: null, lastFocusedEditor: null, statusText: 'מוכן', suppressNextEndRestart: false, lastTranscriptAt: 0, bufferText: '', discardUntilFinal: false, interim: { editor: null, type: null, node: null, prefix: '', rawText: '', previewText: '', inputStart: null, inputLength: 0 }, dragging: { active: false, moved: false, pointerId: null, startedOnMic: false, canDrag: false, startX: 0, startY: 0, startLeft: 0, startTop: 0 } }; let host; let shadow; let wrap; let micButton; let expandButton; let statusPill; let languageSelect; let settingsButton; let settingsPanel; let bufferPreview; let copyButton; let pasteButton; let clearButton; let resetPositionButton; function gmGet(key, fallback) { try { if (typeof GM_getValue === 'function') { return GM_getValue(`${STORAGE_PREFIX}${key}`, fallback); } } catch (_) {} try { const raw = localStorage.getItem(`${STORAGE_PREFIX}${key}`); return raw === null ? fallback : JSON.parse(raw); } catch (_) { return fallback; } } function gmSet(key, value) { try { if (typeof GM_setValue === 'function') { GM_setValue(`${STORAGE_PREFIX}${key}`, value); return; } } catch (_) {} try { localStorage.setItem(`${STORAGE_PREFIX}${key}`, JSON.stringify(value)); } catch (_) {} } function getSiteStorageKey(name) { const origin = (() => { try { return window.location.origin || `${window.location.protocol}//${window.location.host}`; } catch (_) { return 'unknown-origin'; } })(); return `${name}:${origin}`; } function getPositionStorageKey() { return getSiteStorageKey('position'); } function loadSettings() { const loaded = { ...DEFAULT_SETTINGS }; for (const key of Object.keys(DEFAULT_SETTINGS)) { loaded[key] = gmGet(key, DEFAULT_SETTINGS[key]); } state.settings = loaded; const loadedPosition = gmGet(getPositionStorageKey(), DEFAULT_POSITION); state.position = { ...DEFAULT_POSITION, ...(loadedPosition && typeof loadedPosition === 'object' ? loadedPosition : {}) }; } function saveSetting(key, value) { state.settings[key] = value; gmSet(key, value); if (key === 'language' && state.recognition && state.listening) { setStatus('השפה תעודכן בהפעלה הבאה'); } refreshUiVisibilitySettings(); } function savePosition(position) { state.position = { ...state.position, ...position }; gmSet(getPositionStorageKey(), state.position); } function supportsSpeechRecognition() { return Boolean(window.SpeechRecognition || window.webkitSpeechRecognition); } function getSpeechRecognitionCtor() { return window.SpeechRecognition || window.webkitSpeechRecognition; } function isInsideOwnUi(node) { if (!node) return false; return node === host || (host && host.contains(node)); } function isEditableElement(element) { if (!element || !(element instanceof HTMLElement)) return false; if (isInsideOwnUi(element)) return false; if (element.isContentEditable) return true; const tag = element.tagName.toLowerCase(); if (tag === 'textarea') return !element.disabled && !element.readOnly; if (tag === 'input') { const input = element; return INPUT_TYPES.has((input.getAttribute('type') || '').toLowerCase()) && !input.disabled && !input.readOnly; } return false; } function getEditorType(editor) { if (!editor) return null; const tag = editor.tagName?.toLowerCase(); if (tag === 'textarea' || tag === 'input') return 'input'; if (editor.isContentEditable) return 'contenteditable'; return null; } function isElementVisible(el) { if (!el || !(el instanceof HTMLElement)) return false; const rect = el.getBoundingClientRect(); const style = getComputedStyle(el); return rect.width > 0 && rect.height > 0 && style.visibility !== 'hidden' && style.display !== 'none'; } function getCandidateEditors() { const selectors = [ 'textarea', 'input[type="text"]', 'input[type="search"]', 'input[type="email"]', 'input[type="url"]', 'input[type="tel"]', 'input[type="password"]', 'input[type="number"]', 'input:not([type])', '[contenteditable="true"]', '[contenteditable="plaintext-only"]', '[role="textbox"]' ]; return Array.from(document.querySelectorAll(selectors.join(','))) .filter((el) => isEditableElement(el) && isElementVisible(el)); } function getDeepActiveElement(root = document) { let active = root.activeElement; while (active && active.shadowRoot && active.shadowRoot.activeElement) { active = active.shadowRoot.activeElement; } return active; } function getBestEditor() { const active = getDeepActiveElement(); if (isEditableElement(active) && isElementVisible(active)) return active; if (state.lastFocusedEditor && isEditableElement(state.lastFocusedEditor) && isElementVisible(state.lastFocusedEditor)) { return state.lastFocusedEditor; } if (state.currentEditor && isEditableElement(state.currentEditor) && isElementVisible(state.currentEditor)) { return state.currentEditor; } const selection = window.getSelection(); if (selection && selection.anchorNode) { const candidates = getCandidateEditors(); for (const editor of candidates) { if (editor.contains(selection.anchorNode)) return editor; } } return null; } function setStatus(text) { state.statusText = text; if (statusPill) statusPill.textContent = text; } function getLanguageLabel(value) { return LANGUAGES.find((lang) => lang.value === value)?.label || value; } function normalizeTranscript(rawText) { let text = (rawText || '').replace(/\s+/g, ' ').trim(); if (!text) return ''; if (state.settings.autoCapitalize && state.settings.language.startsWith('en')) { text = text.charAt(0).toUpperCase() + text.slice(1); } return text; } function getInputPrefix(editor, start) { if (!state.settings.addSpaceBeforeText) return ''; const left = String(editor.value || '').slice(0, Math.max(0, start ?? editor.selectionStart ?? 0)); if (!left) return ''; return /[\s\n]$/.test(left) ? '' : ' '; } function editorContainsSelection(editor) { const selection = window.getSelection(); if (!selection || selection.rangeCount === 0) return false; return !!selection.anchorNode && editor.contains(selection.anchorNode); } function moveCaretToEndContentEditable(editor) { editor.focus(); const selection = window.getSelection(); if (!selection) return; const range = document.createRange(); range.selectNodeContents(editor); range.collapse(false); selection.removeAllRanges(); selection.addRange(range); } function getPlainText(editor) { return (editor.innerText || editor.textContent || '').replace(/\u00a0/g, ' '); } function getContentEditablePrefix(editor) { if (!state.settings.addSpaceBeforeText) return ''; let leftText = ''; const selection = window.getSelection(); if (selection && selection.rangeCount > 0 && editorContainsSelection(editor)) { try { const probe = selection.getRangeAt(0).cloneRange(); probe.setStart(editor, 0); leftText = probe.toString(); } catch (_) { leftText = getPlainText(editor); } } else { leftText = getPlainText(editor); } if (!leftText) return ''; return /[\s\n]$/.test(leftText) ? '' : ' '; } function dispatchInput(editor, data = '') { try { editor.dispatchEvent(new InputEvent('input', { inputType: 'insertText', data, bubbles: true, composed: true })); } catch (_) { editor.dispatchEvent(new Event('input', { bubbles: true })); } } function dispatchContentEditableDomChange(editor) { // In React/ProseMirror-style editors, sending InputEvent data for every // interim result can make the app append each partial transcript again. // We already changed the DOM ourselves, so notify the editor with a // neutral input event instead of another "insertText" payload. try { editor.dispatchEvent(new InputEvent('input', { inputType: 'insertReplacementText', data: null, bubbles: true, composed: true })); } catch (_) { editor.dispatchEvent(new Event('input', { bubbles: true })); } } function setInputRangeText(editor, replacement, start, end) { editor.focus(); try { editor.setRangeText(replacement, start, end, 'end'); } catch (_) { const value = String(editor.value || ''); editor.value = `${value.slice(0, start)}${replacement}${value.slice(end)}`; const caret = start + replacement.length; try { editor.setSelectionRange(caret, caret); } catch (_) {} } dispatchInput(editor, replacement); } function insertTextIntoInput(editor, rawText) { const normalized = normalizeTranscript(rawText); if (!normalized) return false; editor.focus(); const start = editor.selectionStart ?? String(editor.value || '').length; const end = editor.selectionEnd ?? start; const text = `${getInputPrefix(editor, start)}${normalized}`; setInputRangeText(editor, text, start, end); return true; } function getTextPositionInContentEditable(editor, charOffset) { const target = Math.max(0, Number(charOffset) || 0); const walker = document.createTreeWalker(editor, NodeFilter.SHOW_TEXT); let remaining = target; let lastPosition = null; let node; while ((node = walker.nextNode())) { const length = node.nodeValue.length; if (remaining <= length) return { node, offset: remaining }; remaining -= length; lastPosition = { node, offset: length }; } return lastPosition || { node: editor, offset: 0 }; } function selectContentEditableTextRange(editor, startOffset, endOffset) { const start = getTextPositionInContentEditable(editor, startOffset); const end = getTextPositionInContentEditable(editor, endOffset); if (!start || !end) return false; try { const range = document.createRange(); range.setStart(start.node, start.offset); range.setEnd(end.node, end.offset); const selection = window.getSelection(); if (!selection) return false; selection.removeAllRanges(); selection.addRange(range); return true; } catch (_) { return false; } } function selectLastCharactersBeforeCaret(editor, charCount) { const length = Math.max(0, Number(charCount) || 0); if (!length) return true; editor.focus(); if (!editorContainsSelection(editor)) moveCaretToEndContentEditable(editor); const selection = window.getSelection(); if (!selection || selection.rangeCount === 0) return false; try { const caret = selection.getRangeAt(0).cloneRange(); caret.collapse(false); const beforeCaret = caret.cloneRange(); beforeCaret.selectNodeContents(editor); beforeCaret.setEnd(caret.endContainer, caret.endOffset); const endOffset = beforeCaret.toString().length; const startOffset = Math.max(0, endOffset - length); return selectContentEditableTextRange(editor, startOffset, endOffset); } catch (_) { return false; } } function selectLastMatchingText(editor, text) { const needle = String(text || ''); if (!needle) return false; const haystack = getPlainText(editor); const startOffset = haystack.lastIndexOf(needle); if (startOffset < 0) return false; return selectContentEditableTextRange(editor, startOffset, startOffset + needle.length); } function replaceContentEditableSelection(editor, text) { editor.focus(); let inserted = false; try { if (document.queryCommandSupported && document.queryCommandSupported('insertText')) { inserted = document.execCommand('insertText', false, text); } } catch (_) { inserted = false; } if (!inserted) { const selection = window.getSelection(); if (!selection || selection.rangeCount === 0) return false; const range = selection.getRangeAt(0); range.deleteContents(); const textNode = document.createTextNode(text); range.insertNode(textNode); range.setStartAfter(textNode); range.setEndAfter(textNode); selection.removeAllRanges(); selection.addRange(range); dispatchContentEditableDomChange(editor); } return true; } function replaceTrackedContentEditableText(editor, oldLength, oldText, newText) { editor.focus(); const previous = String(oldText || ''); if (oldLength > 0 || previous) { // Never replace arbitrary text near the caret. If the live preview was // manually deleted/changed by the user, do not bring it back later. if (!previous || !selectLastMatchingText(editor, previous)) return false; } return replaceContentEditableSelection(editor, newText); } function insertTextIntoContentEditable(editor, rawText) { const normalized = normalizeTranscript(rawText); if (!normalized) return false; const text = `${getContentEditablePrefix(editor)}${normalized}`; editor.focus(); if (!editorContainsSelection(editor)) { moveCaretToEndContentEditable(editor); } let inserted = false; try { if (document.queryCommandSupported && document.queryCommandSupported('insertText')) { inserted = document.execCommand('insertText', false, text); } } catch (_) { inserted = false; } if (!inserted) { const selection = window.getSelection(); if (!selection || selection.rangeCount === 0) return false; const range = selection.getRangeAt(0); range.deleteContents(); const textNode = document.createTextNode(text); range.insertNode(textNode); range.setStartAfter(textNode); range.setEndAfter(textNode); selection.removeAllRanges(); selection.addRange(range); dispatchContentEditableDomChange(editor); } return true; } function insertTextIntoEditor(editor, rawText) { const type = getEditorType(editor); if (type === 'input') return insertTextIntoInput(editor, rawText); if (type === 'contenteditable') return insertTextIntoContentEditable(editor, rawText); return false; } function resetInterimState() { state.interim = { editor: null, type: null, node: null, prefix: '', rawText: '', previewText: '', inputStart: null, inputLength: 0 }; } function clearInterim({ keepVisualText = false, dispatch = true } = {}) { const interim = state.interim; if (!interim.editor) { resetInterimState(); return; } if (interim.type === 'contenteditable' && interim.node && document.contains(interim.node)) { if (keepVisualText && interim.node.textContent) { const textNode = document.createTextNode(interim.node.textContent); const parent = interim.node.parentNode; if (parent) { parent.replaceChild(textNode, interim.node); const selection = window.getSelection(); if (selection) { const range = document.createRange(); range.setStartAfter(textNode); range.collapse(true); selection.removeAllRanges(); selection.addRange(range); } } } else { interim.node.remove(); } if (dispatch) dispatchContentEditableDomChange(interim.editor); } if (interim.type === 'input' && interim.inputStart !== null && !keepVisualText) { const start = interim.inputStart; const end = start + interim.inputLength; setInputRangeText(interim.editor, '', start, end); } resetInterimState(); } function inputPreviewIsIntact(interim = state.interim) { if (!interim.editor || interim.type !== 'input' || interim.inputStart === null || interim.inputLength <= 0) return true; const value = String(interim.editor.value || ''); const expected = String(interim.previewText || ''); if (!expected) return true; const exact = value.slice(interim.inputStart, interim.inputStart + interim.inputLength) === expected; if (exact) return true; const fallbackIndex = value.lastIndexOf(expected); if (fallbackIndex >= 0) { interim.inputStart = fallbackIndex; return true; } return false; } function contentEditablePreviewIsIntact(interim = state.interim) { if (!interim.editor || interim.type !== 'contenteditable' || interim.inputLength <= 0) return true; const expected = String(interim.previewText || ''); if (!expected) return true; return getPlainText(interim.editor).includes(expected); } function interimPreviewIsIntact() { if (!state.interim.editor) return true; if (state.interim.type === 'input') return inputPreviewIsIntact(); if (state.interim.type === 'contenteditable') return contentEditablePreviewIsIntact(); return true; } function discardCurrentSpeechSegment() { state.discardUntilFinal = true; resetInterimState(); updateBufferPreview(); setStatus('הטקסט נמחק — מדלג על המקטע'); } function renderInterimInInput(editor, rawText) { const normalized = normalizeTranscript(rawText); if (!normalized) return false; editor.focus(); if (state.interim.editor !== editor || state.interim.type !== 'input') { clearInterim({ keepVisualText: true }); const start = editor.selectionStart ?? String(editor.value || '').length; state.interim.editor = editor; state.interim.type = 'input'; state.interim.inputStart = start; state.interim.prefix = getInputPrefix(editor, start); state.interim.inputLength = 0; state.interim.previewText = ''; } else if (!inputPreviewIsIntact()) { discardCurrentSpeechSegment(); return false; } const text = `${state.interim.prefix}${normalized}`; const start = state.interim.inputStart; const end = start + state.interim.inputLength; setInputRangeText(editor, text, start, end); state.interim.inputLength = text.length; state.interim.previewText = text; state.interim.rawText = rawText; return true; } function ensureInterimNode(editor) { if (state.interim.editor === editor && state.interim.type === 'contenteditable' && state.interim.node && document.contains(state.interim.node)) { return state.interim.node; } clearInterim({ keepVisualText: true }); editor.focus(); if (!editorContainsSelection(editor)) { moveCaretToEndContentEditable(editor); } const selection = window.getSelection(); if (!selection || selection.rangeCount === 0) return null; const span = document.createElement('span'); span.setAttribute(INTERIM_ATTR, '1'); span.style.opacity = '0.72'; span.style.whiteSpace = 'pre-wrap'; span.style.borderBottom = '1px dotted currentColor'; state.interim.editor = editor; state.interim.type = 'contenteditable'; state.interim.node = span; state.interim.prefix = getContentEditablePrefix(editor); state.interim.rawText = ''; span.textContent = state.interim.prefix; const range = selection.getRangeAt(0); range.deleteContents(); range.insertNode(span); const after = document.createRange(); after.setStartAfter(span); after.collapse(true); selection.removeAllRanges(); selection.addRange(after); return span; } function renderInterimInContentEditable(editor, rawText) { const normalized = normalizeTranscript(rawText); if (!normalized) return false; if (state.interim.editor !== editor || state.interim.type !== 'contenteditable') { clearInterim({ keepVisualText: true }); editor.focus(); if (!editorContainsSelection(editor)) moveCaretToEndContentEditable(editor); state.interim.editor = editor; state.interim.type = 'contenteditable'; state.interim.node = null; state.interim.prefix = getContentEditablePrefix(editor); state.interim.inputLength = 0; state.interim.previewText = ''; state.interim.rawText = ''; } const text = `${state.interim.prefix}${normalized}`; const ok = replaceTrackedContentEditableText( editor, state.interim.inputLength, state.interim.previewText, text ); if (!ok) { discardCurrentSpeechSegment(); return false; } state.interim.inputLength = text.length; state.interim.previewText = text; state.interim.rawText = rawText; return true; } function renderInterimTranscript(editor, rawText) { const type = getEditorType(editor); if (type === 'input') return renderInterimInInput(editor, rawText); if (type === 'contenteditable') return renderInterimInContentEditable(editor, rawText); return false; } function commitFinalToInput(editor, rawText) { const normalized = normalizeTranscript(rawText); if (!normalized) return false; if (state.interim.editor === editor && state.interim.type === 'input' && state.interim.inputStart !== null) { if (!inputPreviewIsIntact()) { resetInterimState(); setStatus('דילג על הטקסט שנמחק'); return false; } const text = `${state.interim.prefix}${normalized}`; const start = state.interim.inputStart; const end = start + state.interim.inputLength; setInputRangeText(editor, text, start, end); resetInterimState(); return true; } return insertTextIntoInput(editor, normalized); } function commitFinalToContentEditable(editor, rawText) { const normalized = normalizeTranscript(rawText); if (!normalized) return false; if (state.interim.editor === editor && state.interim.type === 'contenteditable') { const finalText = `${state.interim.prefix}${normalized}`; const ok = replaceTrackedContentEditableText( editor, state.interim.inputLength, state.interim.previewText, finalText ); resetInterimState(); if (!ok) setStatus('דילג על הטקסט שנמחק'); return ok; } return insertTextIntoContentEditable(editor, normalized); } function commitFinalTranscript(editor, rawText) { const type = getEditorType(editor); if (type === 'input') return commitFinalToInput(editor, rawText); if (type === 'contenteditable') return commitFinalToContentEditable(editor, rawText); return false; } function appendToBuffer(rawText) { const normalized = normalizeTranscript(rawText); if (!normalized) return; const prefix = state.bufferText && !/[\s\n]$/.test(state.bufferText) ? ' ' : ''; state.bufferText = `${state.bufferText}${prefix}${normalized}`; updateBufferPreview(); } function updateBufferPreview(interimText = '') { if (!bufferPreview) return; const interim = normalizeTranscript(interimText); const full = `${state.bufferText}${interim ? `${state.bufferText ? ' ' : ''}${interim}` : ''}`.trim(); bufferPreview.textContent = full || 'אין טקסט שמור עדיין.'; bufferPreview.classList.toggle('empty', !full); } async function copyBufferToClipboard() { const text = state.bufferText.trim(); if (!text) { setStatus('אין טקסט להעתקה'); return; } try { await navigator.clipboard.writeText(text); setStatus('הטקסט הועתק'); } catch (_) { setStatus('לא ניתן להעתיק אוטומטית'); } } function pasteBufferToActiveEditor() { const text = state.bufferText.trim(); if (!text) { setStatus('אין טקסט להדבקה'); return; } const editor = getBestEditor(); if (!editor) { setStatus('בחר קודם שדה טקסט בדף'); return; } if (insertTextIntoEditor(editor, text)) { state.bufferText = ''; updateBufferPreview(); setStatus('הטקסט הודבק לשדה הפעיל'); } } function updateButtonState() { if (!micButton) return; const supported = supportsSpeechRecognition(); micButton.classList.toggle('recording', state.listening); micButton.classList.toggle('unsupported', !supported); micButton.title = supported ? (state.listening ? 'עצור תמלול · Alt+גרירה להזזה' : 'התחל תמלול · Alt+גרירה להזזה') : 'הדפדפן לא תומך בתמלול קולי'; micButton.setAttribute('aria-pressed', state.listening ? 'true' : 'false'); if (!supported) { setStatus('לא נתמך בדפדפן הזה'); } } function refreshUiVisibilitySettings() { if (!wrap) return; wrap.classList.toggle('hide-status', !state.settings.showStatus); wrap.classList.toggle('hide-lang', !state.settings.showLanguageSelect); } function applyPosition() { if (!wrap) return; if (state.position.mode === 'free' && Number.isFinite(state.position.left) && Number.isFinite(state.position.top)) { // Intentionally do not clamp to the viewport. Users can move the button // partially or fully outside the visible page, and restore it with // Alt+Shift+M or the reset button when the panel is visible. wrap.style.left = `${state.position.left}px`; wrap.style.top = `${state.position.top}px`; wrap.style.right = 'auto'; wrap.style.bottom = 'auto'; return; } wrap.style.left = 'auto'; wrap.style.top = 'auto'; wrap.style.right = `${state.position.right ?? 24}px`; wrap.style.bottom = `${state.position.bottom ?? 24}px`; } function resetPosition() { savePosition({ ...DEFAULT_POSITION }); requestAnimationFrame(applyPosition); setStatus('המיקום אופס לאתר הזה'); } function ensureUi() { if (state.mounted) return; host = document.createElement('div'); host.id = APP_ID; host.setAttribute('aria-hidden', 'false'); document.documentElement.appendChild(host); shadow = host.attachShadow({ mode: 'open' }); shadow.innerHTML = ` <style> :host { all: initial; } .wrap { position: fixed; z-index: 2147483647; display: inline-flex; align-items: center; gap: 8px; direction: rtl; font-family: Arial, sans-serif; user-select: none; touch-action: none; } .options { display: inline-flex; align-items: center; gap: 8px; } .wrap.collapsed .options, .wrap.collapsed .panel { display: none !important; } .status, .lang, .settings-toggle, .expand-toggle { background: rgba(17, 24, 39, 0.94); color: #fff; border-radius: 999px; box-shadow: 0 8px 20px rgba(0,0,0,0.18); } .status { padding: 7px 11px; font-size: 12px; line-height: 1; white-space: nowrap; max-width: min(280px, 40vw); overflow: hidden; text-overflow: ellipsis; } .hide-status .status { display: none; } .hide-lang .lang { display: none; } .lang { border: 0; outline: 0; padding: 7px 10px; font-size: 12px; cursor: pointer; max-width: 125px; } .mic-shell { position: relative; width: 54px; height: 54px; display: inline-flex; align-items: center; justify-content: center; flex: 0 0 auto; } .btn, .settings-toggle, .expand-toggle { border: none; cursor: pointer; display: inline-flex; align-items: center; justify-content: center; transition: transform 120ms ease, box-shadow 120ms ease, opacity 120ms ease, background 120ms ease; } .btn { width: 54px; height: 54px; border-radius: 999px; background: #1a73e8; color: #fff; box-shadow: 0 12px 26px rgba(26, 115, 232, 0.34); position: relative; } .btn:hover, .settings-toggle:hover, .expand-toggle:hover { transform: translateY(-1px); } .btn:active, .settings-toggle:active, .expand-toggle:active { transform: translateY(0); } .btn.recording { background: #d93025; box-shadow: 0 12px 26px rgba(217, 48, 37, 0.38); animation: pulse 1.2s infinite; } .btn.unsupported { cursor: not-allowed; opacity: 0.65; box-shadow: none; animation: none; } .wrap.dragging .btn { cursor: grabbing; } .settings-toggle { width: 34px; height: 34px; font-size: 16px; } .expand-toggle { position: absolute; left: -3px; bottom: -3px; z-index: 2; width: 22px; height: 22px; padding: 0; font-size: 12px; font-weight: 700; line-height: 1; box-shadow: 0 6px 14px rgba(0,0,0,0.22); } .wrap.expanded .expand-toggle { background: rgba(17, 24, 39, 0.98); } .icon { width: 26px; height: 26px; fill: currentColor; pointer-events: none; } .panel { position: absolute; right: 0; bottom: 66px; display: none; width: min(320px, calc(100vw - 24px)); padding: 12px; border-radius: 16px; background: rgba(255,255,255,0.99); color: #202124; box-shadow: 0 16px 42px rgba(0,0,0,0.24); border: 1px solid rgba(0,0,0,0.08); } .panel.open { display: block; } .panel-title { font-weight: 700; margin: 0 0 10px; font-size: 14px; } .row { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin: 9px 0; font-size: 13px; } .row label { cursor: pointer; } .panel select { max-width: 150px; border: 1px solid #dadce0; border-radius: 8px; padding: 5px 7px; background: #fff; } .buffer { margin-top: 10px; padding: 9px; border: 1px solid #e0e0e0; border-radius: 12px; background: #f8fafd; max-height: 110px; overflow: auto; white-space: pre-wrap; line-height: 1.45; font-size: 12px; user-select: text; } .buffer.empty { color: #6b7280; } .actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 10px; } .panel-button { border: 1px solid #dadce0; background: #fff; border-radius: 999px; padding: 6px 10px; cursor: pointer; font-size: 12px; } .panel-button:hover { background: #f1f3f4; } .hint { margin: 10px 0 0; font-size: 11px; color: #5f6368; line-height: 1.45; } @keyframes pulse { 0% { transform: scale(1); } 50% { transform: scale(1.06); } 100% { transform: scale(1); } } </style> <div class="wrap collapsed" id="wrap"> <div class="options" id="quickOptions"> <div class="status" id="status">מוכן</div> <select class="lang" id="languageSelect" title="שפת תמלול"></select> <button class="settings-toggle" id="settingsBtn" type="button" title="הגדרות">⚙</button> </div> <div class="mic-shell" id="micShell"> <button class="btn" id="micBtn" type="button" title="התחל תמלול"> <svg class="icon" viewBox="0 0 24 24" aria-hidden="true"> <path d="M12 15a3.75 3.75 0 0 0 3.75-3.75V6.75a3.75 3.75 0 1 0-7.5 0v4.5A3.75 3.75 0 0 0 12 15Zm6-3.75a.75.75 0 0 1 1.5 0A7.5 7.5 0 0 1 12.75 18.7V21a.75.75 0 0 1-1.5 0v-2.3A7.5 7.5 0 0 1 4.5 11.25a.75.75 0 0 1 1.5 0 6 6 0 0 0 12 0Z"></path> </svg> </button> <button class="expand-toggle" id="expandBtn" type="button" title="פתח אפשרויות" aria-expanded="false">▴</button> </div> <div class="panel" id="settingsPanel"> <p class="panel-title">תמלול קולי גלובלי</p> <div class="row"> <label for="panelLanguage">שפה</label> <select id="panelLanguage"></select> </div> <div class="row"> <label for="continuousToggle">האזנה רציפה</label> <input id="continuousToggle" type="checkbox"> </div> <div class="row"> <label for="spaceToggle">להוסיף רווח לפני התמלול</label> <input id="spaceToggle" type="checkbox"> </div> <div class="row"> <label for="capitalizeToggle">Capital באנגלית</label> <input id="capitalizeToggle" type="checkbox"> </div> <div class="row"> <label for="statusToggle">להציג סטטוס</label> <input id="statusToggle" type="checkbox"> </div> <div class="row"> <label for="langToggle">להציג בחירת שפה</label> <input id="langToggle" type="checkbox"> </div> <div class="buffer empty" id="bufferPreview">אין טקסט שמור עדיין.</div> <div class="actions"> <button class="panel-button" id="pasteBtn" type="button">הדבק לשדה הפעיל</button> <button class="panel-button" id="copyBtn" type="button">העתק</button> <button class="panel-button" id="clearBtn" type="button">נקה</button> <button class="panel-button" id="resetPositionBtn" type="button">אפס מיקום</button> </div> <div class="hint"> לחץ בתוך שדה טקסט ואז על המיקרופון. כדי להזיז את הכפתור: החזק Alt וגרור את המיקרופון. המיקום נשמר בנפרד לכל אתר. אם הכפתור יצא מהמסך, Alt+Shift+M מחזיר אותו לברירת המחדל. אם אין שדה פעיל, התמלול נשמר כאן ואפשר להעתיק/להדביק אותו אחר כך. </div> </div> </div> `; wrap = shadow.getElementById('wrap'); micButton = shadow.getElementById('micBtn'); expandButton = shadow.getElementById('expandBtn'); statusPill = shadow.getElementById('status'); languageSelect = shadow.getElementById('languageSelect'); settingsButton = shadow.getElementById('settingsBtn'); settingsPanel = shadow.getElementById('settingsPanel'); bufferPreview = shadow.getElementById('bufferPreview'); copyButton = shadow.getElementById('copyBtn'); pasteButton = shadow.getElementById('pasteBtn'); clearButton = shadow.getElementById('clearBtn'); resetPositionButton = shadow.getElementById('resetPositionBtn'); hydrateLanguageSelects(); hydratePanelControls(); bindUiEvents(); state.mounted = true; refreshUiVisibilitySettings(); updateBufferPreview(); updateButtonState(); requestAnimationFrame(applyPosition); } function setExpanded(expanded) { if (!wrap || !expandButton) return; wrap.classList.toggle('expanded', Boolean(expanded)); wrap.classList.toggle('collapsed', !expanded); expandButton.textContent = expanded ? '▾' : '▴'; expandButton.title = expanded ? 'סגור אפשרויות' : 'פתח אפשרויות'; expandButton.setAttribute('aria-expanded', expanded ? 'true' : 'false'); if (settingsPanel) settingsPanel.classList.toggle('open', Boolean(expanded)); requestAnimationFrame(applyPosition); } function toggleExpanded() { setExpanded(!wrap.classList.contains('expanded')); } function hydrateLanguageSelects() { const panelLanguage = shadow.getElementById('panelLanguage'); const optionsHtml = LANGUAGES.map((lang) => `<option value="${lang.value}">${lang.label}</option>`).join(''); languageSelect.innerHTML = optionsHtml; panelLanguage.innerHTML = optionsHtml; languageSelect.value = state.settings.language; panelLanguage.value = state.settings.language; const onLanguageChange = (value) => { saveSetting('language', value); languageSelect.value = value; panelLanguage.value = value; setStatus(`שפה: ${getLanguageLabel(value)}`); }; languageSelect.addEventListener('change', () => onLanguageChange(languageSelect.value)); panelLanguage.addEventListener('change', () => onLanguageChange(panelLanguage.value)); } function hydratePanelControls() { const continuousToggle = shadow.getElementById('continuousToggle'); const spaceToggle = shadow.getElementById('spaceToggle'); const capitalizeToggle = shadow.getElementById('capitalizeToggle'); const statusToggle = shadow.getElementById('statusToggle'); const langToggle = shadow.getElementById('langToggle'); continuousToggle.checked = Boolean(state.settings.continuous); spaceToggle.checked = Boolean(state.settings.addSpaceBeforeText); capitalizeToggle.checked = Boolean(state.settings.autoCapitalize); statusToggle.checked = Boolean(state.settings.showStatus); langToggle.checked = Boolean(state.settings.showLanguageSelect); continuousToggle.addEventListener('change', () => saveSetting('continuous', continuousToggle.checked)); spaceToggle.addEventListener('change', () => saveSetting('addSpaceBeforeText', spaceToggle.checked)); capitalizeToggle.addEventListener('change', () => saveSetting('autoCapitalize', capitalizeToggle.checked)); statusToggle.addEventListener('change', () => saveSetting('showStatus', statusToggle.checked)); langToggle.addEventListener('change', () => saveSetting('showLanguageSelect', langToggle.checked)); } function bindUiEvents() { const preventFocusLoss = (event) => { event.preventDefault(); event.stopPropagation(); }; micButton.addEventListener('pointerdown', onPointerDownForDragAndClick, true); for (const el of [expandButton, settingsButton, languageSelect, copyButton, pasteButton, clearButton, resetPositionButton]) { el.addEventListener('pointerdown', preventFocusLoss, true); } expandButton.addEventListener('click', toggleExpanded); settingsButton.addEventListener('click', () => { setExpanded(true); settingsPanel.classList.toggle('open'); requestAnimationFrame(applyPosition); }); copyButton.addEventListener('click', copyBufferToClipboard); pasteButton.addEventListener('click', pasteBufferToActiveEditor); clearButton.addEventListener('click', () => { state.bufferText = ''; updateBufferPreview(); setStatus('הטקסט השמור נמחק'); }); resetPositionButton.addEventListener('click', resetPosition); document.addEventListener('click', (event) => { const path = event.composedPath ? event.composedPath() : []; if (!path.includes(host)) { setExpanded(false); } }, true); } function onPointerDownForDragAndClick(event) { event.preventDefault(); event.stopPropagation(); const rect = wrap.getBoundingClientRect(); const path = typeof event.composedPath === 'function' ? event.composedPath() : []; const startedOnMic = event.currentTarget === micButton || path.includes(micButton); state.dragging = { active: true, moved: false, pointerId: event.pointerId, startedOnMic, canDrag: Boolean(event.altKey && startedOnMic), startX: event.clientX, startY: event.clientY, startLeft: rect.left, startTop: rect.top }; if (state.dragging.canDrag) wrap.classList.add('dragging'); try { event.currentTarget.setPointerCapture(event.pointerId); } catch (_) {} window.addEventListener('pointermove', onPointerMoveDrag, true); window.addEventListener('pointerup', onPointerUpDrag, true); window.addEventListener('pointercancel', onPointerCancelDrag, true); } function onPointerMoveDrag(event) { if (!state.dragging.active || event.pointerId !== state.dragging.pointerId) return; if (!state.dragging.canDrag) return; const dx = event.clientX - state.dragging.startX; const dy = event.clientY - state.dragging.startY; if (!state.dragging.moved && Math.hypot(dx, dy) < 6) return; state.dragging.moved = true; const left = state.dragging.startLeft + dx; const top = state.dragging.startTop + dy; wrap.style.left = `${left}px`; wrap.style.top = `${top}px`; wrap.style.right = 'auto'; wrap.style.bottom = 'auto'; } function onPointerUpDrag(event) { if (!state.dragging.active || event.pointerId !== state.dragging.pointerId) return; const wasDrag = state.dragging.moved; const rect = wrap.getBoundingClientRect(); window.removeEventListener('pointermove', onPointerMoveDrag, true); window.removeEventListener('pointerup', onPointerUpDrag, true); window.removeEventListener('pointercancel', onPointerCancelDrag, true); wrap.classList.remove('dragging'); if (wasDrag) { savePosition({ mode: 'free', left: rect.left, top: rect.top, right: null, bottom: null }); // Keep the UI quiet after dragging; the saved position is obvious visually. setStatus(state.listening ? 'מקשיב...' : 'מוכן'); } else { const path = typeof event.composedPath === 'function' ? event.composedPath() : []; const endedOnMic = event.target === micButton || micButton.contains(event.target) || path.includes(micButton); if (!state.dragging.canDrag && (state.dragging.startedOnMic || endedOnMic)) toggleListening(); } state.dragging.active = false; state.dragging.moved = false; state.dragging.startedOnMic = false; state.dragging.canDrag = false; } function onPointerCancelDrag(event) { if (!state.dragging.active || event.pointerId !== state.dragging.pointerId) return; window.removeEventListener('pointermove', onPointerMoveDrag, true); window.removeEventListener('pointerup', onPointerUpDrag, true); window.removeEventListener('pointercancel', onPointerCancelDrag, true); wrap.classList.remove('dragging'); state.dragging.active = false; state.dragging.moved = false; state.dragging.startedOnMic = false; state.dragging.canDrag = false; } function toggleListening() { if (!supportsSpeechRecognition()) { setStatus('הדפדפן לא תומך בתמלול קולי'); updateButtonState(); return; } if (state.listening) stopListening(); else startListening(); } function createRecognition() { const Recognition = getSpeechRecognitionCtor(); const recognition = new Recognition(); recognition.lang = state.settings.language || 'he-IL'; recognition.interimResults = true; recognition.continuous = Boolean(state.settings.continuous); recognition.maxAlternatives = 1; recognition.onstart = () => { state.listening = true; setStatus('מקשיב...'); updateButtonState(); }; recognition.onresult = (event) => { let finalText = ''; let interimText = ''; for (let i = event.resultIndex; i < event.results.length; i += 1) { const result = event.results[i]; const transcript = result[0]?.transcript || ''; if (result.isFinal) finalText += transcript; else interimText += transcript; } if (state.interim.editor && !interimPreviewIsIntact()) { discardCurrentSpeechSegment(); } if (state.discardUntilFinal) { if (finalText.trim()) { state.discardUntilFinal = false; state.lastTranscriptAt = Date.now(); updateBufferPreview(); setStatus('דילג על הטקסט שנמחק'); } else if (interimText.trim()) { updateBufferPreview(); setStatus('הטקסט נמחק — מדלג על המקטע'); } return; } const editor = getBestEditor(); if (editor) { state.currentEditor = editor; state.lastFocusedEditor = editor; if (finalText.trim()) { const committed = commitFinalTranscript(editor, finalText); if (committed) updateBufferPreview(finalText); state.lastTranscriptAt = Date.now(); } if (interimText.trim()) { renderInterimTranscript(editor, interimText); updateBufferPreview(interimText); setStatus(`מתמלל: ${normalizeTranscript(interimText).slice(0, 46)}`); } else if (finalText.trim()) { clearInterim({ keepVisualText: false }); setStatus('ממשיך להאזין...'); } return; } if (finalText.trim()) { appendToBuffer(finalText); state.lastTranscriptAt = Date.now(); setStatus('נשמר בחלונית'); } if (interimText.trim()) { updateBufferPreview(interimText); setStatus(`מתמלל ללא שדה: ${normalizeTranscript(interimText).slice(0, 38)}`); } }; recognition.onerror = (event) => { const code = event.error || 'unknown'; const messages = { 'not-allowed': 'אין הרשאה למיקרופון', 'service-not-allowed': 'שירות התמלול נחסם', 'audio-capture': 'לא נמצא מיקרופון', 'no-speech': 'לא זוהה דיבור', 'network': 'שגיאת רשת בתמלול', 'aborted': 'התמלול הופסק' }; if (code === 'not-allowed' || code === 'service-not-allowed') { clearInterim({ keepVisualText: true }); state.suppressNextEndRestart = true; } setStatus(messages[code] || `שגיאה: ${code}`); }; recognition.onend = () => { if (state.interim.editor && !state.discardUntilFinal) { clearInterim({ keepVisualText: true }); } else if (state.discardUntilFinal) { resetInterimState(); } const shouldRestart = state.listening && state.settings.continuous && !state.suppressNextEndRestart; if (shouldRestart) { try { recognition.start(); return; } catch (_) {} } state.listening = false; state.recognition = null; state.suppressNextEndRestart = false; state.discardUntilFinal = false; if (Date.now() - state.lastTranscriptAt > 1200 && ['מקשיב...', 'ממשיך להאזין...'].includes(state.statusText)) { setStatus('מוכן'); } updateButtonState(); }; return recognition; } function startListening() { state.currentEditor = getBestEditor(); if (state.currentEditor) state.lastFocusedEditor = state.currentEditor; state.suppressNextEndRestart = false; try { state.recognition = createRecognition(); state.recognition.start(); } catch (_) { setStatus('לא ניתן להפעיל תמלול כעת'); state.recognition = null; state.listening = false; updateButtonState(); } } function stopListening() { state.suppressNextEndRestart = true; if (state.interim.editor && !interimPreviewIsIntact()) { discardCurrentSpeechSegment(); } // Do not preserve/clear the interim text here. // When SpeechRecognition.stop() is called, Chrome may still emit a final // result after this click. Keeping the interim state alive lets the final // result replace the live preview instead of being inserted a second time. // If no final result arrives, recognition.onend will preserve the current // interim text once. if (state.recognition) { try { state.recognition.stop(); } catch (_) {} } state.listening = false; setStatus('נעצר'); updateButtonState(); } function onFocusIn(event) { const target = event.target; if (isEditableElement(target)) { state.currentEditor = target; state.lastFocusedEditor = target; if (state.listening) setStatus('מקשיב לשדה הפעיל...'); } } function onMouseDown(event) { const target = event.target; if (isEditableElement(target)) { state.currentEditor = target; state.lastFocusedEditor = target; } } function onSelectionChange() { const editor = getBestEditor(); if (editor) { state.currentEditor = editor; state.lastFocusedEditor = editor; } } function onKeyDown(event) { if (event.altKey && event.shiftKey && !event.ctrlKey && !event.metaKey && event.code === 'KeyM') { event.preventDefault(); resetPosition(); } } function registerMenuCommands() { try { if (typeof GM_registerMenuCommand !== 'function') return; GM_registerMenuCommand('Voice to Text: הפעלה/עצירה', toggleListening); GM_registerMenuCommand('Voice to Text: החלף עברית/אנגלית', () => { const next = state.settings.language === 'he-IL' ? 'en-US' : 'he-IL'; saveSetting('language', next); if (languageSelect) languageSelect.value = next; const panelLanguage = shadow?.getElementById('panelLanguage'); if (panelLanguage) panelLanguage.value = next; setStatus(`שפה: ${getLanguageLabel(next)}`); }); GM_registerMenuCommand('Voice to Text: אפס מיקום כפתור', resetPosition); } catch (_) {} } function init() { loadSettings(); ensureUi(); registerMenuCommands(); document.addEventListener('focusin', onFocusIn, true); document.addEventListener('mousedown', onMouseDown, true); document.addEventListener('selectionchange', onSelectionChange, true); document.addEventListener('keydown', onKeyDown, true); window.addEventListener('resize', () => requestAnimationFrame(applyPosition), { passive: true }); setStatus(supportsSpeechRecognition() ? 'מוכן' : 'לא נתמך בדפדפן הזה'); updateButtonState(); } function waitForBodyThenInit() { if (document.body && document.documentElement) { init(); return; } const timer = window.setInterval(() => { if (document.body && document.documentElement) { window.clearInterval(timer); init(); } }, 250); } waitForBodyThenInit(); })();אפשרות 2 - תוסף Chrome רגיל
יש גם גרסה כתוסף Google Chrome, שעובדת אותו דבר כמו הסקריפט, רק בלי צורך ב־Tampermonkey.
התקנה:
מורידים את תיקיית התוסף.
פותחים בכרום:
chrome://extensions/מפעילים Developer mode.
לוחצים על Load unpacked.
בוחרים את תיקיית התוסף.
מרעננים את האתרים הפתוחים.קובץ ה־ZIP לתוסף:
universal-voice-to-text-chrome-extension-v2.6.0.zipלא עובד בדפי מערכת של כרום כמו chrome://extensions.
איכות התמלול תלויה במיקרופון וברעש סביבתי.מי שרוצה לבדוק, לשפר או להעיר על באגים - בשמחה.
@אברהם-גלסר
מה רע ב:
https://dictanote.co/voicein/ -
@אברהם-גלסר
מה רע ב:
https://dictanote.co/voicein/@י.-פל. לא הכרתי, ובעיניי שלי יותר נוח...
-
@אברהם-גלסר
מה רע ב:
https://dictanote.co/voicein/ -
@המלאך גם.
-
בניתי בעזרת ChatGPT כלי קטן שמוסיף כפתור מיקרופון קבוע בדפדפן, ומאפשר להכתיב טקסט ישירות לתוך שדות כתיבה באתרים.

מה הכלי עושה בפועל:
מוסיף כפתור מיקרופון צף בכל אתר.
כברירת מחדל מוצג רק כפתור התמלול עצמו, בלי תפריטים מסביב.
יש חץ קטן לפתיחת כל האפשרויות.
בלחיצה על החץ נפתחות האפשרויות: בחירת שפה, הגדרות, טקסט שתומלל, העתקה/הדבקה וכדומה.
בלחיצה נוספת על החץ הכל נסגר שוב.בלחיצה על כפתור המיקרופון מתחיל תמלול דיבור לטקסט.
לחיצה נוספת עוצרת את התמלול.
הטקסט נכנס בזמן אמת לשדה הכתיבה הפעיל.
עובד עם textarea, input ושדות כתיבה מתקדמים יותר כמו contenteditable.
תומך בעברית, אנגלית ובכמה שפות נוספות.
יש אפשרות לבחור שפת תמלול.
אם אין שדה כתיבה פעיל, הטקסט מופיע בחלונית של הכלי ואפשר להעתיק אותו.אפשר להזיז את כפתור התמלול עם Alt + גרירה על הכפתור.
המיקום נשמר גם אחרי רענון.
המיקום נשמר בנפרד לכל אתר, כלומר אפשר לקבוע מיקום אחד ל-ChatGPT, מיקום אחר לפורום וכו׳.
ככה תוכלו להתאים את המיקום שלו לכל אתר איפה שהכי מתאים לו להיות, למשל בChatGPT:
אם הכפתור נעלם בגלל שהוזז החוצה, אפשר להחזיר אותו למיקום ברירת המחדל באתר הנוכחי עם:
Alt + Shift + M
או:
Ctrl + Alt + Mבנוסף, אם מוחקים ידנית תוך כדי תמלול טקסט שהכלי כתב בתוך תיבת הטקסט, הכלי מתעלם מהמחיקה ולא מחזיר את הטקסט הזה מחדש בסיום ההקלטה.
הכלי מבוסס על מנגנון זיהוי הדיבור של הדפדפן, כך שאין צורך בשרת חיצוני או במפתח API משלכם. בפעם הראשונה הדפדפן יבקש הרשאת מיקרופון.
יש שתי אפשרויות שימוש:
אפשרות 1 - סקריפט Tampermonkey
מתאים למי שכבר משתמש ב־Tampermonkey או רוצה להתקין userscript.
התקנה:
מתקינים Tampermonkey.
יוצרים סקריפט חדש.
מדביקים את הקוד.
שומרים.
מרעננים את האתר שבו רוצים להשתמש.הקוד לסקריפט:
// ==UserScript== // @name Universal Voice to Text - Floating Dictation Button // @namespace https://chat.openai.com/ // @version 2.6.0 // @description Floating draggable speech-to-text button for almost any website. Dictates into focused inputs, textareas and contenteditable editors. // @author Avraham + ChatGPT // @match http://*/* // @match https://*/* // @run-at document-idle // @grant GM_getValue // @grant GM_setValue // @grant GM_registerMenuCommand // ==/UserScript== (() => { 'use strict'; const APP_ID = 'uvtt-floating-root'; const STORAGE_PREFIX = 'uvtt_tm_'; const INTERIM_ATTR = 'data-uvtt-interim'; const DEFAULT_SETTINGS = { language: 'he-IL', continuous: true, autoCapitalize: true, addSpaceBeforeText: true, showStatus: true, showLanguageSelect: true }; const DEFAULT_POSITION = { mode: 'corner', right: 24, bottom: 24, left: null, top: null }; const LANGUAGES = [ { value: 'he-IL', label: 'עברית' }, { value: 'en-US', label: 'English US' }, { value: 'en-GB', label: 'English UK' }, { value: 'ar-SA', label: 'العربية' }, { value: 'fr-FR', label: 'Français' }, { value: 'es-ES', label: 'Español' }, { value: 'ru-RU', label: 'Русский' } ]; const INPUT_TYPES = new Set([ '', 'text', 'search', 'email', 'url', 'tel', 'password', 'number' ]); const state = { mounted: false, listening: false, recognition: null, settings: { ...DEFAULT_SETTINGS }, position: { ...DEFAULT_POSITION }, currentEditor: null, lastFocusedEditor: null, statusText: 'מוכן', suppressNextEndRestart: false, lastTranscriptAt: 0, bufferText: '', discardUntilFinal: false, interim: { editor: null, type: null, node: null, prefix: '', rawText: '', previewText: '', inputStart: null, inputLength: 0 }, dragging: { active: false, moved: false, pointerId: null, startedOnMic: false, canDrag: false, startX: 0, startY: 0, startLeft: 0, startTop: 0 } }; let host; let shadow; let wrap; let micButton; let expandButton; let statusPill; let languageSelect; let settingsButton; let settingsPanel; let bufferPreview; let copyButton; let pasteButton; let clearButton; let resetPositionButton; function gmGet(key, fallback) { try { if (typeof GM_getValue === 'function') { return GM_getValue(`${STORAGE_PREFIX}${key}`, fallback); } } catch (_) {} try { const raw = localStorage.getItem(`${STORAGE_PREFIX}${key}`); return raw === null ? fallback : JSON.parse(raw); } catch (_) { return fallback; } } function gmSet(key, value) { try { if (typeof GM_setValue === 'function') { GM_setValue(`${STORAGE_PREFIX}${key}`, value); return; } } catch (_) {} try { localStorage.setItem(`${STORAGE_PREFIX}${key}`, JSON.stringify(value)); } catch (_) {} } function getSiteStorageKey(name) { const origin = (() => { try { return window.location.origin || `${window.location.protocol}//${window.location.host}`; } catch (_) { return 'unknown-origin'; } })(); return `${name}:${origin}`; } function getPositionStorageKey() { return getSiteStorageKey('position'); } function loadSettings() { const loaded = { ...DEFAULT_SETTINGS }; for (const key of Object.keys(DEFAULT_SETTINGS)) { loaded[key] = gmGet(key, DEFAULT_SETTINGS[key]); } state.settings = loaded; const loadedPosition = gmGet(getPositionStorageKey(), DEFAULT_POSITION); state.position = { ...DEFAULT_POSITION, ...(loadedPosition && typeof loadedPosition === 'object' ? loadedPosition : {}) }; } function saveSetting(key, value) { state.settings[key] = value; gmSet(key, value); if (key === 'language' && state.recognition && state.listening) { setStatus('השפה תעודכן בהפעלה הבאה'); } refreshUiVisibilitySettings(); } function savePosition(position) { state.position = { ...state.position, ...position }; gmSet(getPositionStorageKey(), state.position); } function supportsSpeechRecognition() { return Boolean(window.SpeechRecognition || window.webkitSpeechRecognition); } function getSpeechRecognitionCtor() { return window.SpeechRecognition || window.webkitSpeechRecognition; } function isInsideOwnUi(node) { if (!node) return false; return node === host || (host && host.contains(node)); } function isEditableElement(element) { if (!element || !(element instanceof HTMLElement)) return false; if (isInsideOwnUi(element)) return false; if (element.isContentEditable) return true; const tag = element.tagName.toLowerCase(); if (tag === 'textarea') return !element.disabled && !element.readOnly; if (tag === 'input') { const input = element; return INPUT_TYPES.has((input.getAttribute('type') || '').toLowerCase()) && !input.disabled && !input.readOnly; } return false; } function getEditorType(editor) { if (!editor) return null; const tag = editor.tagName?.toLowerCase(); if (tag === 'textarea' || tag === 'input') return 'input'; if (editor.isContentEditable) return 'contenteditable'; return null; } function isElementVisible(el) { if (!el || !(el instanceof HTMLElement)) return false; const rect = el.getBoundingClientRect(); const style = getComputedStyle(el); return rect.width > 0 && rect.height > 0 && style.visibility !== 'hidden' && style.display !== 'none'; } function getCandidateEditors() { const selectors = [ 'textarea', 'input[type="text"]', 'input[type="search"]', 'input[type="email"]', 'input[type="url"]', 'input[type="tel"]', 'input[type="password"]', 'input[type="number"]', 'input:not([type])', '[contenteditable="true"]', '[contenteditable="plaintext-only"]', '[role="textbox"]' ]; return Array.from(document.querySelectorAll(selectors.join(','))) .filter((el) => isEditableElement(el) && isElementVisible(el)); } function getDeepActiveElement(root = document) { let active = root.activeElement; while (active && active.shadowRoot && active.shadowRoot.activeElement) { active = active.shadowRoot.activeElement; } return active; } function getBestEditor() { const active = getDeepActiveElement(); if (isEditableElement(active) && isElementVisible(active)) return active; if (state.lastFocusedEditor && isEditableElement(state.lastFocusedEditor) && isElementVisible(state.lastFocusedEditor)) { return state.lastFocusedEditor; } if (state.currentEditor && isEditableElement(state.currentEditor) && isElementVisible(state.currentEditor)) { return state.currentEditor; } const selection = window.getSelection(); if (selection && selection.anchorNode) { const candidates = getCandidateEditors(); for (const editor of candidates) { if (editor.contains(selection.anchorNode)) return editor; } } return null; } function setStatus(text) { state.statusText = text; if (statusPill) statusPill.textContent = text; } function getLanguageLabel(value) { return LANGUAGES.find((lang) => lang.value === value)?.label || value; } function normalizeTranscript(rawText) { let text = (rawText || '').replace(/\s+/g, ' ').trim(); if (!text) return ''; if (state.settings.autoCapitalize && state.settings.language.startsWith('en')) { text = text.charAt(0).toUpperCase() + text.slice(1); } return text; } function getInputPrefix(editor, start) { if (!state.settings.addSpaceBeforeText) return ''; const left = String(editor.value || '').slice(0, Math.max(0, start ?? editor.selectionStart ?? 0)); if (!left) return ''; return /[\s\n]$/.test(left) ? '' : ' '; } function editorContainsSelection(editor) { const selection = window.getSelection(); if (!selection || selection.rangeCount === 0) return false; return !!selection.anchorNode && editor.contains(selection.anchorNode); } function moveCaretToEndContentEditable(editor) { editor.focus(); const selection = window.getSelection(); if (!selection) return; const range = document.createRange(); range.selectNodeContents(editor); range.collapse(false); selection.removeAllRanges(); selection.addRange(range); } function getPlainText(editor) { return (editor.innerText || editor.textContent || '').replace(/\u00a0/g, ' '); } function getContentEditablePrefix(editor) { if (!state.settings.addSpaceBeforeText) return ''; let leftText = ''; const selection = window.getSelection(); if (selection && selection.rangeCount > 0 && editorContainsSelection(editor)) { try { const probe = selection.getRangeAt(0).cloneRange(); probe.setStart(editor, 0); leftText = probe.toString(); } catch (_) { leftText = getPlainText(editor); } } else { leftText = getPlainText(editor); } if (!leftText) return ''; return /[\s\n]$/.test(leftText) ? '' : ' '; } function dispatchInput(editor, data = '') { try { editor.dispatchEvent(new InputEvent('input', { inputType: 'insertText', data, bubbles: true, composed: true })); } catch (_) { editor.dispatchEvent(new Event('input', { bubbles: true })); } } function dispatchContentEditableDomChange(editor) { // In React/ProseMirror-style editors, sending InputEvent data for every // interim result can make the app append each partial transcript again. // We already changed the DOM ourselves, so notify the editor with a // neutral input event instead of another "insertText" payload. try { editor.dispatchEvent(new InputEvent('input', { inputType: 'insertReplacementText', data: null, bubbles: true, composed: true })); } catch (_) { editor.dispatchEvent(new Event('input', { bubbles: true })); } } function setInputRangeText(editor, replacement, start, end) { editor.focus(); try { editor.setRangeText(replacement, start, end, 'end'); } catch (_) { const value = String(editor.value || ''); editor.value = `${value.slice(0, start)}${replacement}${value.slice(end)}`; const caret = start + replacement.length; try { editor.setSelectionRange(caret, caret); } catch (_) {} } dispatchInput(editor, replacement); } function insertTextIntoInput(editor, rawText) { const normalized = normalizeTranscript(rawText); if (!normalized) return false; editor.focus(); const start = editor.selectionStart ?? String(editor.value || '').length; const end = editor.selectionEnd ?? start; const text = `${getInputPrefix(editor, start)}${normalized}`; setInputRangeText(editor, text, start, end); return true; } function getTextPositionInContentEditable(editor, charOffset) { const target = Math.max(0, Number(charOffset) || 0); const walker = document.createTreeWalker(editor, NodeFilter.SHOW_TEXT); let remaining = target; let lastPosition = null; let node; while ((node = walker.nextNode())) { const length = node.nodeValue.length; if (remaining <= length) return { node, offset: remaining }; remaining -= length; lastPosition = { node, offset: length }; } return lastPosition || { node: editor, offset: 0 }; } function selectContentEditableTextRange(editor, startOffset, endOffset) { const start = getTextPositionInContentEditable(editor, startOffset); const end = getTextPositionInContentEditable(editor, endOffset); if (!start || !end) return false; try { const range = document.createRange(); range.setStart(start.node, start.offset); range.setEnd(end.node, end.offset); const selection = window.getSelection(); if (!selection) return false; selection.removeAllRanges(); selection.addRange(range); return true; } catch (_) { return false; } } function selectLastCharactersBeforeCaret(editor, charCount) { const length = Math.max(0, Number(charCount) || 0); if (!length) return true; editor.focus(); if (!editorContainsSelection(editor)) moveCaretToEndContentEditable(editor); const selection = window.getSelection(); if (!selection || selection.rangeCount === 0) return false; try { const caret = selection.getRangeAt(0).cloneRange(); caret.collapse(false); const beforeCaret = caret.cloneRange(); beforeCaret.selectNodeContents(editor); beforeCaret.setEnd(caret.endContainer, caret.endOffset); const endOffset = beforeCaret.toString().length; const startOffset = Math.max(0, endOffset - length); return selectContentEditableTextRange(editor, startOffset, endOffset); } catch (_) { return false; } } function selectLastMatchingText(editor, text) { const needle = String(text || ''); if (!needle) return false; const haystack = getPlainText(editor); const startOffset = haystack.lastIndexOf(needle); if (startOffset < 0) return false; return selectContentEditableTextRange(editor, startOffset, startOffset + needle.length); } function replaceContentEditableSelection(editor, text) { editor.focus(); let inserted = false; try { if (document.queryCommandSupported && document.queryCommandSupported('insertText')) { inserted = document.execCommand('insertText', false, text); } } catch (_) { inserted = false; } if (!inserted) { const selection = window.getSelection(); if (!selection || selection.rangeCount === 0) return false; const range = selection.getRangeAt(0); range.deleteContents(); const textNode = document.createTextNode(text); range.insertNode(textNode); range.setStartAfter(textNode); range.setEndAfter(textNode); selection.removeAllRanges(); selection.addRange(range); dispatchContentEditableDomChange(editor); } return true; } function replaceTrackedContentEditableText(editor, oldLength, oldText, newText) { editor.focus(); const previous = String(oldText || ''); if (oldLength > 0 || previous) { // Never replace arbitrary text near the caret. If the live preview was // manually deleted/changed by the user, do not bring it back later. if (!previous || !selectLastMatchingText(editor, previous)) return false; } return replaceContentEditableSelection(editor, newText); } function insertTextIntoContentEditable(editor, rawText) { const normalized = normalizeTranscript(rawText); if (!normalized) return false; const text = `${getContentEditablePrefix(editor)}${normalized}`; editor.focus(); if (!editorContainsSelection(editor)) { moveCaretToEndContentEditable(editor); } let inserted = false; try { if (document.queryCommandSupported && document.queryCommandSupported('insertText')) { inserted = document.execCommand('insertText', false, text); } } catch (_) { inserted = false; } if (!inserted) { const selection = window.getSelection(); if (!selection || selection.rangeCount === 0) return false; const range = selection.getRangeAt(0); range.deleteContents(); const textNode = document.createTextNode(text); range.insertNode(textNode); range.setStartAfter(textNode); range.setEndAfter(textNode); selection.removeAllRanges(); selection.addRange(range); dispatchContentEditableDomChange(editor); } return true; } function insertTextIntoEditor(editor, rawText) { const type = getEditorType(editor); if (type === 'input') return insertTextIntoInput(editor, rawText); if (type === 'contenteditable') return insertTextIntoContentEditable(editor, rawText); return false; } function resetInterimState() { state.interim = { editor: null, type: null, node: null, prefix: '', rawText: '', previewText: '', inputStart: null, inputLength: 0 }; } function clearInterim({ keepVisualText = false, dispatch = true } = {}) { const interim = state.interim; if (!interim.editor) { resetInterimState(); return; } if (interim.type === 'contenteditable' && interim.node && document.contains(interim.node)) { if (keepVisualText && interim.node.textContent) { const textNode = document.createTextNode(interim.node.textContent); const parent = interim.node.parentNode; if (parent) { parent.replaceChild(textNode, interim.node); const selection = window.getSelection(); if (selection) { const range = document.createRange(); range.setStartAfter(textNode); range.collapse(true); selection.removeAllRanges(); selection.addRange(range); } } } else { interim.node.remove(); } if (dispatch) dispatchContentEditableDomChange(interim.editor); } if (interim.type === 'input' && interim.inputStart !== null && !keepVisualText) { const start = interim.inputStart; const end = start + interim.inputLength; setInputRangeText(interim.editor, '', start, end); } resetInterimState(); } function inputPreviewIsIntact(interim = state.interim) { if (!interim.editor || interim.type !== 'input' || interim.inputStart === null || interim.inputLength <= 0) return true; const value = String(interim.editor.value || ''); const expected = String(interim.previewText || ''); if (!expected) return true; const exact = value.slice(interim.inputStart, interim.inputStart + interim.inputLength) === expected; if (exact) return true; const fallbackIndex = value.lastIndexOf(expected); if (fallbackIndex >= 0) { interim.inputStart = fallbackIndex; return true; } return false; } function contentEditablePreviewIsIntact(interim = state.interim) { if (!interim.editor || interim.type !== 'contenteditable' || interim.inputLength <= 0) return true; const expected = String(interim.previewText || ''); if (!expected) return true; return getPlainText(interim.editor).includes(expected); } function interimPreviewIsIntact() { if (!state.interim.editor) return true; if (state.interim.type === 'input') return inputPreviewIsIntact(); if (state.interim.type === 'contenteditable') return contentEditablePreviewIsIntact(); return true; } function discardCurrentSpeechSegment() { state.discardUntilFinal = true; resetInterimState(); updateBufferPreview(); setStatus('הטקסט נמחק — מדלג על המקטע'); } function renderInterimInInput(editor, rawText) { const normalized = normalizeTranscript(rawText); if (!normalized) return false; editor.focus(); if (state.interim.editor !== editor || state.interim.type !== 'input') { clearInterim({ keepVisualText: true }); const start = editor.selectionStart ?? String(editor.value || '').length; state.interim.editor = editor; state.interim.type = 'input'; state.interim.inputStart = start; state.interim.prefix = getInputPrefix(editor, start); state.interim.inputLength = 0; state.interim.previewText = ''; } else if (!inputPreviewIsIntact()) { discardCurrentSpeechSegment(); return false; } const text = `${state.interim.prefix}${normalized}`; const start = state.interim.inputStart; const end = start + state.interim.inputLength; setInputRangeText(editor, text, start, end); state.interim.inputLength = text.length; state.interim.previewText = text; state.interim.rawText = rawText; return true; } function ensureInterimNode(editor) { if (state.interim.editor === editor && state.interim.type === 'contenteditable' && state.interim.node && document.contains(state.interim.node)) { return state.interim.node; } clearInterim({ keepVisualText: true }); editor.focus(); if (!editorContainsSelection(editor)) { moveCaretToEndContentEditable(editor); } const selection = window.getSelection(); if (!selection || selection.rangeCount === 0) return null; const span = document.createElement('span'); span.setAttribute(INTERIM_ATTR, '1'); span.style.opacity = '0.72'; span.style.whiteSpace = 'pre-wrap'; span.style.borderBottom = '1px dotted currentColor'; state.interim.editor = editor; state.interim.type = 'contenteditable'; state.interim.node = span; state.interim.prefix = getContentEditablePrefix(editor); state.interim.rawText = ''; span.textContent = state.interim.prefix; const range = selection.getRangeAt(0); range.deleteContents(); range.insertNode(span); const after = document.createRange(); after.setStartAfter(span); after.collapse(true); selection.removeAllRanges(); selection.addRange(after); return span; } function renderInterimInContentEditable(editor, rawText) { const normalized = normalizeTranscript(rawText); if (!normalized) return false; if (state.interim.editor !== editor || state.interim.type !== 'contenteditable') { clearInterim({ keepVisualText: true }); editor.focus(); if (!editorContainsSelection(editor)) moveCaretToEndContentEditable(editor); state.interim.editor = editor; state.interim.type = 'contenteditable'; state.interim.node = null; state.interim.prefix = getContentEditablePrefix(editor); state.interim.inputLength = 0; state.interim.previewText = ''; state.interim.rawText = ''; } const text = `${state.interim.prefix}${normalized}`; const ok = replaceTrackedContentEditableText( editor, state.interim.inputLength, state.interim.previewText, text ); if (!ok) { discardCurrentSpeechSegment(); return false; } state.interim.inputLength = text.length; state.interim.previewText = text; state.interim.rawText = rawText; return true; } function renderInterimTranscript(editor, rawText) { const type = getEditorType(editor); if (type === 'input') return renderInterimInInput(editor, rawText); if (type === 'contenteditable') return renderInterimInContentEditable(editor, rawText); return false; } function commitFinalToInput(editor, rawText) { const normalized = normalizeTranscript(rawText); if (!normalized) return false; if (state.interim.editor === editor && state.interim.type === 'input' && state.interim.inputStart !== null) { if (!inputPreviewIsIntact()) { resetInterimState(); setStatus('דילג על הטקסט שנמחק'); return false; } const text = `${state.interim.prefix}${normalized}`; const start = state.interim.inputStart; const end = start + state.interim.inputLength; setInputRangeText(editor, text, start, end); resetInterimState(); return true; } return insertTextIntoInput(editor, normalized); } function commitFinalToContentEditable(editor, rawText) { const normalized = normalizeTranscript(rawText); if (!normalized) return false; if (state.interim.editor === editor && state.interim.type === 'contenteditable') { const finalText = `${state.interim.prefix}${normalized}`; const ok = replaceTrackedContentEditableText( editor, state.interim.inputLength, state.interim.previewText, finalText ); resetInterimState(); if (!ok) setStatus('דילג על הטקסט שנמחק'); return ok; } return insertTextIntoContentEditable(editor, normalized); } function commitFinalTranscript(editor, rawText) { const type = getEditorType(editor); if (type === 'input') return commitFinalToInput(editor, rawText); if (type === 'contenteditable') return commitFinalToContentEditable(editor, rawText); return false; } function appendToBuffer(rawText) { const normalized = normalizeTranscript(rawText); if (!normalized) return; const prefix = state.bufferText && !/[\s\n]$/.test(state.bufferText) ? ' ' : ''; state.bufferText = `${state.bufferText}${prefix}${normalized}`; updateBufferPreview(); } function updateBufferPreview(interimText = '') { if (!bufferPreview) return; const interim = normalizeTranscript(interimText); const full = `${state.bufferText}${interim ? `${state.bufferText ? ' ' : ''}${interim}` : ''}`.trim(); bufferPreview.textContent = full || 'אין טקסט שמור עדיין.'; bufferPreview.classList.toggle('empty', !full); } async function copyBufferToClipboard() { const text = state.bufferText.trim(); if (!text) { setStatus('אין טקסט להעתקה'); return; } try { await navigator.clipboard.writeText(text); setStatus('הטקסט הועתק'); } catch (_) { setStatus('לא ניתן להעתיק אוטומטית'); } } function pasteBufferToActiveEditor() { const text = state.bufferText.trim(); if (!text) { setStatus('אין טקסט להדבקה'); return; } const editor = getBestEditor(); if (!editor) { setStatus('בחר קודם שדה טקסט בדף'); return; } if (insertTextIntoEditor(editor, text)) { state.bufferText = ''; updateBufferPreview(); setStatus('הטקסט הודבק לשדה הפעיל'); } } function updateButtonState() { if (!micButton) return; const supported = supportsSpeechRecognition(); micButton.classList.toggle('recording', state.listening); micButton.classList.toggle('unsupported', !supported); micButton.title = supported ? (state.listening ? 'עצור תמלול · Alt+גרירה להזזה' : 'התחל תמלול · Alt+גרירה להזזה') : 'הדפדפן לא תומך בתמלול קולי'; micButton.setAttribute('aria-pressed', state.listening ? 'true' : 'false'); if (!supported) { setStatus('לא נתמך בדפדפן הזה'); } } function refreshUiVisibilitySettings() { if (!wrap) return; wrap.classList.toggle('hide-status', !state.settings.showStatus); wrap.classList.toggle('hide-lang', !state.settings.showLanguageSelect); } function applyPosition() { if (!wrap) return; if (state.position.mode === 'free' && Number.isFinite(state.position.left) && Number.isFinite(state.position.top)) { // Intentionally do not clamp to the viewport. Users can move the button // partially or fully outside the visible page, and restore it with // Alt+Shift+M or the reset button when the panel is visible. wrap.style.left = `${state.position.left}px`; wrap.style.top = `${state.position.top}px`; wrap.style.right = 'auto'; wrap.style.bottom = 'auto'; return; } wrap.style.left = 'auto'; wrap.style.top = 'auto'; wrap.style.right = `${state.position.right ?? 24}px`; wrap.style.bottom = `${state.position.bottom ?? 24}px`; } function resetPosition() { savePosition({ ...DEFAULT_POSITION }); requestAnimationFrame(applyPosition); setStatus('המיקום אופס לאתר הזה'); } function ensureUi() { if (state.mounted) return; host = document.createElement('div'); host.id = APP_ID; host.setAttribute('aria-hidden', 'false'); document.documentElement.appendChild(host); shadow = host.attachShadow({ mode: 'open' }); shadow.innerHTML = ` <style> :host { all: initial; } .wrap { position: fixed; z-index: 2147483647; display: inline-flex; align-items: center; gap: 8px; direction: rtl; font-family: Arial, sans-serif; user-select: none; touch-action: none; } .options { display: inline-flex; align-items: center; gap: 8px; } .wrap.collapsed .options, .wrap.collapsed .panel { display: none !important; } .status, .lang, .settings-toggle, .expand-toggle { background: rgba(17, 24, 39, 0.94); color: #fff; border-radius: 999px; box-shadow: 0 8px 20px rgba(0,0,0,0.18); } .status { padding: 7px 11px; font-size: 12px; line-height: 1; white-space: nowrap; max-width: min(280px, 40vw); overflow: hidden; text-overflow: ellipsis; } .hide-status .status { display: none; } .hide-lang .lang { display: none; } .lang { border: 0; outline: 0; padding: 7px 10px; font-size: 12px; cursor: pointer; max-width: 125px; } .mic-shell { position: relative; width: 54px; height: 54px; display: inline-flex; align-items: center; justify-content: center; flex: 0 0 auto; } .btn, .settings-toggle, .expand-toggle { border: none; cursor: pointer; display: inline-flex; align-items: center; justify-content: center; transition: transform 120ms ease, box-shadow 120ms ease, opacity 120ms ease, background 120ms ease; } .btn { width: 54px; height: 54px; border-radius: 999px; background: #1a73e8; color: #fff; box-shadow: 0 12px 26px rgba(26, 115, 232, 0.34); position: relative; } .btn:hover, .settings-toggle:hover, .expand-toggle:hover { transform: translateY(-1px); } .btn:active, .settings-toggle:active, .expand-toggle:active { transform: translateY(0); } .btn.recording { background: #d93025; box-shadow: 0 12px 26px rgba(217, 48, 37, 0.38); animation: pulse 1.2s infinite; } .btn.unsupported { cursor: not-allowed; opacity: 0.65; box-shadow: none; animation: none; } .wrap.dragging .btn { cursor: grabbing; } .settings-toggle { width: 34px; height: 34px; font-size: 16px; } .expand-toggle { position: absolute; left: -3px; bottom: -3px; z-index: 2; width: 22px; height: 22px; padding: 0; font-size: 12px; font-weight: 700; line-height: 1; box-shadow: 0 6px 14px rgba(0,0,0,0.22); } .wrap.expanded .expand-toggle { background: rgba(17, 24, 39, 0.98); } .icon { width: 26px; height: 26px; fill: currentColor; pointer-events: none; } .panel { position: absolute; right: 0; bottom: 66px; display: none; width: min(320px, calc(100vw - 24px)); padding: 12px; border-radius: 16px; background: rgba(255,255,255,0.99); color: #202124; box-shadow: 0 16px 42px rgba(0,0,0,0.24); border: 1px solid rgba(0,0,0,0.08); } .panel.open { display: block; } .panel-title { font-weight: 700; margin: 0 0 10px; font-size: 14px; } .row { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin: 9px 0; font-size: 13px; } .row label { cursor: pointer; } .panel select { max-width: 150px; border: 1px solid #dadce0; border-radius: 8px; padding: 5px 7px; background: #fff; } .buffer { margin-top: 10px; padding: 9px; border: 1px solid #e0e0e0; border-radius: 12px; background: #f8fafd; max-height: 110px; overflow: auto; white-space: pre-wrap; line-height: 1.45; font-size: 12px; user-select: text; } .buffer.empty { color: #6b7280; } .actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 10px; } .panel-button { border: 1px solid #dadce0; background: #fff; border-radius: 999px; padding: 6px 10px; cursor: pointer; font-size: 12px; } .panel-button:hover { background: #f1f3f4; } .hint { margin: 10px 0 0; font-size: 11px; color: #5f6368; line-height: 1.45; } @keyframes pulse { 0% { transform: scale(1); } 50% { transform: scale(1.06); } 100% { transform: scale(1); } } </style> <div class="wrap collapsed" id="wrap"> <div class="options" id="quickOptions"> <div class="status" id="status">מוכן</div> <select class="lang" id="languageSelect" title="שפת תמלול"></select> <button class="settings-toggle" id="settingsBtn" type="button" title="הגדרות">⚙</button> </div> <div class="mic-shell" id="micShell"> <button class="btn" id="micBtn" type="button" title="התחל תמלול"> <svg class="icon" viewBox="0 0 24 24" aria-hidden="true"> <path d="M12 15a3.75 3.75 0 0 0 3.75-3.75V6.75a3.75 3.75 0 1 0-7.5 0v4.5A3.75 3.75 0 0 0 12 15Zm6-3.75a.75.75 0 0 1 1.5 0A7.5 7.5 0 0 1 12.75 18.7V21a.75.75 0 0 1-1.5 0v-2.3A7.5 7.5 0 0 1 4.5 11.25a.75.75 0 0 1 1.5 0 6 6 0 0 0 12 0Z"></path> </svg> </button> <button class="expand-toggle" id="expandBtn" type="button" title="פתח אפשרויות" aria-expanded="false">▴</button> </div> <div class="panel" id="settingsPanel"> <p class="panel-title">תמלול קולי גלובלי</p> <div class="row"> <label for="panelLanguage">שפה</label> <select id="panelLanguage"></select> </div> <div class="row"> <label for="continuousToggle">האזנה רציפה</label> <input id="continuousToggle" type="checkbox"> </div> <div class="row"> <label for="spaceToggle">להוסיף רווח לפני התמלול</label> <input id="spaceToggle" type="checkbox"> </div> <div class="row"> <label for="capitalizeToggle">Capital באנגלית</label> <input id="capitalizeToggle" type="checkbox"> </div> <div class="row"> <label for="statusToggle">להציג סטטוס</label> <input id="statusToggle" type="checkbox"> </div> <div class="row"> <label for="langToggle">להציג בחירת שפה</label> <input id="langToggle" type="checkbox"> </div> <div class="buffer empty" id="bufferPreview">אין טקסט שמור עדיין.</div> <div class="actions"> <button class="panel-button" id="pasteBtn" type="button">הדבק לשדה הפעיל</button> <button class="panel-button" id="copyBtn" type="button">העתק</button> <button class="panel-button" id="clearBtn" type="button">נקה</button> <button class="panel-button" id="resetPositionBtn" type="button">אפס מיקום</button> </div> <div class="hint"> לחץ בתוך שדה טקסט ואז על המיקרופון. כדי להזיז את הכפתור: החזק Alt וגרור את המיקרופון. המיקום נשמר בנפרד לכל אתר. אם הכפתור יצא מהמסך, Alt+Shift+M מחזיר אותו לברירת המחדל. אם אין שדה פעיל, התמלול נשמר כאן ואפשר להעתיק/להדביק אותו אחר כך. </div> </div> </div> `; wrap = shadow.getElementById('wrap'); micButton = shadow.getElementById('micBtn'); expandButton = shadow.getElementById('expandBtn'); statusPill = shadow.getElementById('status'); languageSelect = shadow.getElementById('languageSelect'); settingsButton = shadow.getElementById('settingsBtn'); settingsPanel = shadow.getElementById('settingsPanel'); bufferPreview = shadow.getElementById('bufferPreview'); copyButton = shadow.getElementById('copyBtn'); pasteButton = shadow.getElementById('pasteBtn'); clearButton = shadow.getElementById('clearBtn'); resetPositionButton = shadow.getElementById('resetPositionBtn'); hydrateLanguageSelects(); hydratePanelControls(); bindUiEvents(); state.mounted = true; refreshUiVisibilitySettings(); updateBufferPreview(); updateButtonState(); requestAnimationFrame(applyPosition); } function setExpanded(expanded) { if (!wrap || !expandButton) return; wrap.classList.toggle('expanded', Boolean(expanded)); wrap.classList.toggle('collapsed', !expanded); expandButton.textContent = expanded ? '▾' : '▴'; expandButton.title = expanded ? 'סגור אפשרויות' : 'פתח אפשרויות'; expandButton.setAttribute('aria-expanded', expanded ? 'true' : 'false'); if (settingsPanel) settingsPanel.classList.toggle('open', Boolean(expanded)); requestAnimationFrame(applyPosition); } function toggleExpanded() { setExpanded(!wrap.classList.contains('expanded')); } function hydrateLanguageSelects() { const panelLanguage = shadow.getElementById('panelLanguage'); const optionsHtml = LANGUAGES.map((lang) => `<option value="${lang.value}">${lang.label}</option>`).join(''); languageSelect.innerHTML = optionsHtml; panelLanguage.innerHTML = optionsHtml; languageSelect.value = state.settings.language; panelLanguage.value = state.settings.language; const onLanguageChange = (value) => { saveSetting('language', value); languageSelect.value = value; panelLanguage.value = value; setStatus(`שפה: ${getLanguageLabel(value)}`); }; languageSelect.addEventListener('change', () => onLanguageChange(languageSelect.value)); panelLanguage.addEventListener('change', () => onLanguageChange(panelLanguage.value)); } function hydratePanelControls() { const continuousToggle = shadow.getElementById('continuousToggle'); const spaceToggle = shadow.getElementById('spaceToggle'); const capitalizeToggle = shadow.getElementById('capitalizeToggle'); const statusToggle = shadow.getElementById('statusToggle'); const langToggle = shadow.getElementById('langToggle'); continuousToggle.checked = Boolean(state.settings.continuous); spaceToggle.checked = Boolean(state.settings.addSpaceBeforeText); capitalizeToggle.checked = Boolean(state.settings.autoCapitalize); statusToggle.checked = Boolean(state.settings.showStatus); langToggle.checked = Boolean(state.settings.showLanguageSelect); continuousToggle.addEventListener('change', () => saveSetting('continuous', continuousToggle.checked)); spaceToggle.addEventListener('change', () => saveSetting('addSpaceBeforeText', spaceToggle.checked)); capitalizeToggle.addEventListener('change', () => saveSetting('autoCapitalize', capitalizeToggle.checked)); statusToggle.addEventListener('change', () => saveSetting('showStatus', statusToggle.checked)); langToggle.addEventListener('change', () => saveSetting('showLanguageSelect', langToggle.checked)); } function bindUiEvents() { const preventFocusLoss = (event) => { event.preventDefault(); event.stopPropagation(); }; micButton.addEventListener('pointerdown', onPointerDownForDragAndClick, true); for (const el of [expandButton, settingsButton, languageSelect, copyButton, pasteButton, clearButton, resetPositionButton]) { el.addEventListener('pointerdown', preventFocusLoss, true); } expandButton.addEventListener('click', toggleExpanded); settingsButton.addEventListener('click', () => { setExpanded(true); settingsPanel.classList.toggle('open'); requestAnimationFrame(applyPosition); }); copyButton.addEventListener('click', copyBufferToClipboard); pasteButton.addEventListener('click', pasteBufferToActiveEditor); clearButton.addEventListener('click', () => { state.bufferText = ''; updateBufferPreview(); setStatus('הטקסט השמור נמחק'); }); resetPositionButton.addEventListener('click', resetPosition); document.addEventListener('click', (event) => { const path = event.composedPath ? event.composedPath() : []; if (!path.includes(host)) { setExpanded(false); } }, true); } function onPointerDownForDragAndClick(event) { event.preventDefault(); event.stopPropagation(); const rect = wrap.getBoundingClientRect(); const path = typeof event.composedPath === 'function' ? event.composedPath() : []; const startedOnMic = event.currentTarget === micButton || path.includes(micButton); state.dragging = { active: true, moved: false, pointerId: event.pointerId, startedOnMic, canDrag: Boolean(event.altKey && startedOnMic), startX: event.clientX, startY: event.clientY, startLeft: rect.left, startTop: rect.top }; if (state.dragging.canDrag) wrap.classList.add('dragging'); try { event.currentTarget.setPointerCapture(event.pointerId); } catch (_) {} window.addEventListener('pointermove', onPointerMoveDrag, true); window.addEventListener('pointerup', onPointerUpDrag, true); window.addEventListener('pointercancel', onPointerCancelDrag, true); } function onPointerMoveDrag(event) { if (!state.dragging.active || event.pointerId !== state.dragging.pointerId) return; if (!state.dragging.canDrag) return; const dx = event.clientX - state.dragging.startX; const dy = event.clientY - state.dragging.startY; if (!state.dragging.moved && Math.hypot(dx, dy) < 6) return; state.dragging.moved = true; const left = state.dragging.startLeft + dx; const top = state.dragging.startTop + dy; wrap.style.left = `${left}px`; wrap.style.top = `${top}px`; wrap.style.right = 'auto'; wrap.style.bottom = 'auto'; } function onPointerUpDrag(event) { if (!state.dragging.active || event.pointerId !== state.dragging.pointerId) return; const wasDrag = state.dragging.moved; const rect = wrap.getBoundingClientRect(); window.removeEventListener('pointermove', onPointerMoveDrag, true); window.removeEventListener('pointerup', onPointerUpDrag, true); window.removeEventListener('pointercancel', onPointerCancelDrag, true); wrap.classList.remove('dragging'); if (wasDrag) { savePosition({ mode: 'free', left: rect.left, top: rect.top, right: null, bottom: null }); // Keep the UI quiet after dragging; the saved position is obvious visually. setStatus(state.listening ? 'מקשיב...' : 'מוכן'); } else { const path = typeof event.composedPath === 'function' ? event.composedPath() : []; const endedOnMic = event.target === micButton || micButton.contains(event.target) || path.includes(micButton); if (!state.dragging.canDrag && (state.dragging.startedOnMic || endedOnMic)) toggleListening(); } state.dragging.active = false; state.dragging.moved = false; state.dragging.startedOnMic = false; state.dragging.canDrag = false; } function onPointerCancelDrag(event) { if (!state.dragging.active || event.pointerId !== state.dragging.pointerId) return; window.removeEventListener('pointermove', onPointerMoveDrag, true); window.removeEventListener('pointerup', onPointerUpDrag, true); window.removeEventListener('pointercancel', onPointerCancelDrag, true); wrap.classList.remove('dragging'); state.dragging.active = false; state.dragging.moved = false; state.dragging.startedOnMic = false; state.dragging.canDrag = false; } function toggleListening() { if (!supportsSpeechRecognition()) { setStatus('הדפדפן לא תומך בתמלול קולי'); updateButtonState(); return; } if (state.listening) stopListening(); else startListening(); } function createRecognition() { const Recognition = getSpeechRecognitionCtor(); const recognition = new Recognition(); recognition.lang = state.settings.language || 'he-IL'; recognition.interimResults = true; recognition.continuous = Boolean(state.settings.continuous); recognition.maxAlternatives = 1; recognition.onstart = () => { state.listening = true; setStatus('מקשיב...'); updateButtonState(); }; recognition.onresult = (event) => { let finalText = ''; let interimText = ''; for (let i = event.resultIndex; i < event.results.length; i += 1) { const result = event.results[i]; const transcript = result[0]?.transcript || ''; if (result.isFinal) finalText += transcript; else interimText += transcript; } if (state.interim.editor && !interimPreviewIsIntact()) { discardCurrentSpeechSegment(); } if (state.discardUntilFinal) { if (finalText.trim()) { state.discardUntilFinal = false; state.lastTranscriptAt = Date.now(); updateBufferPreview(); setStatus('דילג על הטקסט שנמחק'); } else if (interimText.trim()) { updateBufferPreview(); setStatus('הטקסט נמחק — מדלג על המקטע'); } return; } const editor = getBestEditor(); if (editor) { state.currentEditor = editor; state.lastFocusedEditor = editor; if (finalText.trim()) { const committed = commitFinalTranscript(editor, finalText); if (committed) updateBufferPreview(finalText); state.lastTranscriptAt = Date.now(); } if (interimText.trim()) { renderInterimTranscript(editor, interimText); updateBufferPreview(interimText); setStatus(`מתמלל: ${normalizeTranscript(interimText).slice(0, 46)}`); } else if (finalText.trim()) { clearInterim({ keepVisualText: false }); setStatus('ממשיך להאזין...'); } return; } if (finalText.trim()) { appendToBuffer(finalText); state.lastTranscriptAt = Date.now(); setStatus('נשמר בחלונית'); } if (interimText.trim()) { updateBufferPreview(interimText); setStatus(`מתמלל ללא שדה: ${normalizeTranscript(interimText).slice(0, 38)}`); } }; recognition.onerror = (event) => { const code = event.error || 'unknown'; const messages = { 'not-allowed': 'אין הרשאה למיקרופון', 'service-not-allowed': 'שירות התמלול נחסם', 'audio-capture': 'לא נמצא מיקרופון', 'no-speech': 'לא זוהה דיבור', 'network': 'שגיאת רשת בתמלול', 'aborted': 'התמלול הופסק' }; if (code === 'not-allowed' || code === 'service-not-allowed') { clearInterim({ keepVisualText: true }); state.suppressNextEndRestart = true; } setStatus(messages[code] || `שגיאה: ${code}`); }; recognition.onend = () => { if (state.interim.editor && !state.discardUntilFinal) { clearInterim({ keepVisualText: true }); } else if (state.discardUntilFinal) { resetInterimState(); } const shouldRestart = state.listening && state.settings.continuous && !state.suppressNextEndRestart; if (shouldRestart) { try { recognition.start(); return; } catch (_) {} } state.listening = false; state.recognition = null; state.suppressNextEndRestart = false; state.discardUntilFinal = false; if (Date.now() - state.lastTranscriptAt > 1200 && ['מקשיב...', 'ממשיך להאזין...'].includes(state.statusText)) { setStatus('מוכן'); } updateButtonState(); }; return recognition; } function startListening() { state.currentEditor = getBestEditor(); if (state.currentEditor) state.lastFocusedEditor = state.currentEditor; state.suppressNextEndRestart = false; try { state.recognition = createRecognition(); state.recognition.start(); } catch (_) { setStatus('לא ניתן להפעיל תמלול כעת'); state.recognition = null; state.listening = false; updateButtonState(); } } function stopListening() { state.suppressNextEndRestart = true; if (state.interim.editor && !interimPreviewIsIntact()) { discardCurrentSpeechSegment(); } // Do not preserve/clear the interim text here. // When SpeechRecognition.stop() is called, Chrome may still emit a final // result after this click. Keeping the interim state alive lets the final // result replace the live preview instead of being inserted a second time. // If no final result arrives, recognition.onend will preserve the current // interim text once. if (state.recognition) { try { state.recognition.stop(); } catch (_) {} } state.listening = false; setStatus('נעצר'); updateButtonState(); } function onFocusIn(event) { const target = event.target; if (isEditableElement(target)) { state.currentEditor = target; state.lastFocusedEditor = target; if (state.listening) setStatus('מקשיב לשדה הפעיל...'); } } function onMouseDown(event) { const target = event.target; if (isEditableElement(target)) { state.currentEditor = target; state.lastFocusedEditor = target; } } function onSelectionChange() { const editor = getBestEditor(); if (editor) { state.currentEditor = editor; state.lastFocusedEditor = editor; } } function onKeyDown(event) { if (event.altKey && event.shiftKey && !event.ctrlKey && !event.metaKey && event.code === 'KeyM') { event.preventDefault(); resetPosition(); } } function registerMenuCommands() { try { if (typeof GM_registerMenuCommand !== 'function') return; GM_registerMenuCommand('Voice to Text: הפעלה/עצירה', toggleListening); GM_registerMenuCommand('Voice to Text: החלף עברית/אנגלית', () => { const next = state.settings.language === 'he-IL' ? 'en-US' : 'he-IL'; saveSetting('language', next); if (languageSelect) languageSelect.value = next; const panelLanguage = shadow?.getElementById('panelLanguage'); if (panelLanguage) panelLanguage.value = next; setStatus(`שפה: ${getLanguageLabel(next)}`); }); GM_registerMenuCommand('Voice to Text: אפס מיקום כפתור', resetPosition); } catch (_) {} } function init() { loadSettings(); ensureUi(); registerMenuCommands(); document.addEventListener('focusin', onFocusIn, true); document.addEventListener('mousedown', onMouseDown, true); document.addEventListener('selectionchange', onSelectionChange, true); document.addEventListener('keydown', onKeyDown, true); window.addEventListener('resize', () => requestAnimationFrame(applyPosition), { passive: true }); setStatus(supportsSpeechRecognition() ? 'מוכן' : 'לא נתמך בדפדפן הזה'); updateButtonState(); } function waitForBodyThenInit() { if (document.body && document.documentElement) { init(); return; } const timer = window.setInterval(() => { if (document.body && document.documentElement) { window.clearInterval(timer); init(); } }, 250); } waitForBodyThenInit(); })();אפשרות 2 - תוסף Chrome רגיל
יש גם גרסה כתוסף Google Chrome, שעובדת אותו דבר כמו הסקריפט, רק בלי צורך ב־Tampermonkey.
התקנה:
מורידים את תיקיית התוסף.
פותחים בכרום:
chrome://extensions/מפעילים Developer mode.
לוחצים על Load unpacked.
בוחרים את תיקיית התוסף.
מרעננים את האתרים הפתוחים.קובץ ה־ZIP לתוסף:
universal-voice-to-text-chrome-extension-v2.6.0.zipלא עובד בדפי מערכת של כרום כמו chrome://extensions.
איכות התמלול תלויה במיקרופון וברעש סביבתי.מי שרוצה לבדוק, לשפר או להעיר על באגים - בשמחה.
@אברהם-גלסר עובד בנטפרי?
-
@אברהם-גלסר עובד בנטפרי?
@טופ-שבמתמחים לא יודע, אמור.