Working perfect.
This commit is contained in:
parent
86844e330e
commit
e96cb5bdaa
@ -2,69 +2,60 @@ class STTButton extends HTMLElement {
|
|||||||
/** monitor target attribute so it can change on-the-fly */
|
/** monitor target attribute so it can change on-the-fly */
|
||||||
static get observedAttributes() { return ['target']; }
|
static get observedAttributes() { return ['target']; }
|
||||||
|
|
||||||
simulateTypingWithEvents(element, text, delay = 100) {
|
simulateTypingWithEvents(element, text, delay = 100) {
|
||||||
let resolver = null
|
let resolver = null;
|
||||||
let promise = new Promise((resolve, reject)=>{
|
let promise = new Promise((resolve) => {
|
||||||
resolver = resolve
|
resolver = resolve;
|
||||||
})
|
|
||||||
let index = 0;
|
|
||||||
|
|
||||||
function triggerEvent(type, key) {
|
|
||||||
const event = new KeyboardEvent(type, {
|
|
||||||
key: key,
|
|
||||||
bubbles: true,
|
|
||||||
cancelable: true,
|
|
||||||
});
|
});
|
||||||
element.dispatchEvent(event);
|
let index = 0;
|
||||||
}
|
|
||||||
|
|
||||||
const interval = setInterval(() => {
|
function triggerEvent(type, key) {
|
||||||
if (index < text.length) {
|
const event = new KeyboardEvent(type, {
|
||||||
const char = text.charAt(index);
|
key: key,
|
||||||
|
bubbles: true,
|
||||||
// Trigger keydown
|
cancelable: true,
|
||||||
triggerEvent('keydown', char);
|
});
|
||||||
|
element.dispatchEvent(event);
|
||||||
// Update the value
|
|
||||||
if (element.isContentEditable) {
|
|
||||||
// For contentEditable elements
|
|
||||||
document.execCommand('insertText', false, char);
|
|
||||||
} else {
|
|
||||||
// For input or textarea
|
|
||||||
element.value += char;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Trigger keypress
|
|
||||||
triggerEvent('keypress', char);
|
|
||||||
|
|
||||||
// Trigger keyup
|
|
||||||
triggerEvent('keyup', char);
|
|
||||||
|
|
||||||
index++;
|
|
||||||
} else {
|
|
||||||
clearInterval(interval);
|
|
||||||
resolver()
|
|
||||||
}
|
}
|
||||||
}, delay);
|
|
||||||
return promise
|
const interval = setInterval(() => {
|
||||||
}
|
if (index < text.length) {
|
||||||
|
const char = text.charAt(index);
|
||||||
|
|
||||||
|
triggerEvent('keydown', char);
|
||||||
|
|
||||||
|
if (element.isContentEditable) {
|
||||||
|
document.execCommand('insertText', false, char);
|
||||||
|
} else {
|
||||||
|
element.value += char;
|
||||||
|
}
|
||||||
|
|
||||||
|
triggerEvent('keypress', char);
|
||||||
|
triggerEvent('keyup', char);
|
||||||
|
|
||||||
|
index++;
|
||||||
|
} else {
|
||||||
|
clearInterval(interval);
|
||||||
|
resolver();
|
||||||
|
}
|
||||||
|
}, delay);
|
||||||
|
|
||||||
|
return promise;
|
||||||
|
}
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super();
|
super();
|
||||||
this.attachShadow({mode: 'open'});
|
this.attachShadow({ mode: 'open' });
|
||||||
|
|
||||||
/* —— UI —— */
|
const btn = document.createElement('button');
|
||||||
const btn = document.createElement('button');
|
|
||||||
btn.setAttribute('part', 'button');
|
btn.setAttribute('part', 'button');
|
||||||
btn.setAttribute('aria-label', 'Start voice dictation');
|
btn.setAttribute('aria-label', 'Start voice dictation');
|
||||||
btn.innerHTML = '🎤'; // tiny icon – swap with SVG if you prefer
|
btn.innerHTML = '🎤';
|
||||||
|
|
||||||
const style = document.createElement('style');
|
const style = document.createElement('style');
|
||||||
style.textContent = `
|
style.textContent = `
|
||||||
:host { display:inline-block; }
|
:host { display:inline-block; }
|
||||||
button { all:unset; cursor:pointer;/* font-size:1.6rem;
|
button { all:unset; cursor:pointer; }
|
||||||
padding:.6rem; border-radius:50%; background:#f2f2f2;
|
|
||||||
transition:background .25s, transform .25s, box-shadow .25s;*/ }
|
|
||||||
button:hover { background:#e8e8e8; }
|
button:hover { background:#e8e8e8; }
|
||||||
:host([listening]) button {
|
:host([listening]) button {
|
||||||
background:#d32f2f; color:#fff;
|
background:#d32f2f; color:#fff;
|
||||||
@ -76,106 +67,91 @@ class STTButton extends HTMLElement {
|
|||||||
`;
|
`;
|
||||||
this.shadowRoot.append(style, btn);
|
this.shadowRoot.append(style, btn);
|
||||||
|
|
||||||
/* —— speech recognition setup —— */
|
|
||||||
const SR = window.SpeechRecognition || window.webkitSpeechRecognition;
|
const SR = window.SpeechRecognition || window.webkitSpeechRecognition;
|
||||||
if (!SR) { console.warn('Web Speech API not supported in this browser.'); return; }
|
if (!SR) {
|
||||||
|
console.warn('Web Speech API not supported in this browser.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
this.recog = new SR();
|
this.recog = new SR();
|
||||||
this.recog.lang = 'en-US'; // always English
|
this.recog.lang = 'en-US';
|
||||||
this.recog.continuous = true;
|
this.recog.continuous = true;
|
||||||
this.recog.interimResults = true;
|
this.recog.interimResults = true;
|
||||||
|
|
||||||
/* handle results */
|
let interim = '';
|
||||||
let interim = ''; // grey, volatile
|
let committed = '';
|
||||||
let committed = ''; // sentences we've decided are final
|
|
||||||
|
|
||||||
this.recog.onresult = (e) => {
|
this.recog.onresult = (e) => {
|
||||||
interim = ''; // reset interim on every event
|
interim = '';
|
||||||
|
for (let i = e.resultIndex; i < e.results.length; i++) {
|
||||||
|
const res = e.results[i];
|
||||||
|
const txt = res[0].transcript.trim();
|
||||||
|
|
||||||
for (let i = e.resultIndex; i < e.results.length; i++) {
|
if (res.isFinal) {
|
||||||
const res = e.results[i];
|
const sentence = txt.charAt(0).toUpperCase() + txt.slice(1);
|
||||||
const txt = res[0].transcript.trim();
|
let punctuated = /[.!?]$/.test(sentence) ? sentence : sentence + '.';
|
||||||
|
|
||||||
if (res.isFinal) {
|
committed += punctuated + ' ';
|
||||||
// 1) Capitalise first letter
|
if (this.targetEl) {
|
||||||
const sentence =
|
this.targetEl.focus();
|
||||||
txt.charAt(0).toUpperCase() + txt.slice(1);
|
punctuated = punctuated.replace(/\./g, ".\n");
|
||||||
|
punctuated = punctuated.replace(/\?/g, "?\n");
|
||||||
|
punctuated = punctuated.replace(/\!/g, "!\n");
|
||||||
|
|
||||||
// 2) Ensure closing punctuation
|
this.simulateTypingWithEvents(this.targetEl, punctuated, 1).then(() => {
|
||||||
let punctuated =
|
const chatInput = document.querySelector('chat-input');
|
||||||
/[.!?]$/.test(sentence) ? sentence : sentence + '.';
|
chatInput.finalizeMessage();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
interim += txt + ' ';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
committed += punctuated + ' '; // add to permanent text
|
this.recog.onend = () => {
|
||||||
if (this.targetEl) {
|
// Auto-restart recognition unless we explicitly stopped
|
||||||
this.targetEl.focus()
|
if (this.listening) {
|
||||||
|
try {
|
||||||
punctuated = punctuated.replace(/\./g, ".\n")
|
this.recog.start(); // attempt restart
|
||||||
punctuated = punctuated.replace(/\?/g, "?\n")
|
} catch (e) {
|
||||||
punctuated = punctuated.replace(/\!/g, "!\n")
|
console.warn('Failed to restart speech recognition:', e);
|
||||||
this.simulateTypingWithEvents(this.targetEl, punctuated,1).then(()=>{
|
}
|
||||||
const chatInput = document.querySelector('chat-input')
|
}
|
||||||
chatInput.finalizeMessage()
|
};
|
||||||
})
|
|
||||||
//triggerEvent('keydown', "Enter");
|
|
||||||
|
|
||||||
|
|
||||||
//this.targetEl.value = committed + interim
|
|
||||||
|
|
||||||
/*this.targetElement.dispatchEvent(new ChangeEvent('change', {
|
|
||||||
bubbles: true,
|
|
||||||
cancelable: true
|
|
||||||
}));*/
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
} else {
|
|
||||||
interim += txt + ' '; // live but volatile
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* --- paint to DOM --- */
|
|
||||||
};
|
|
||||||
|
|
||||||
/* const transcript = Array.from(e.results)
|
|
||||||
.map(res => res[0].transcript)
|
|
||||||
.join('');
|
|
||||||
if (this.targetEl) this.targetEl.value = transcript;
|
|
||||||
console.info(transcript)*/
|
|
||||||
|
|
||||||
|
|
||||||
/* update state when recognition stops unexpectedly */
|
|
||||||
this.recog.onend = () => { this.listening = false; };
|
|
||||||
|
|
||||||
/* click toggles listening state */
|
|
||||||
btn.addEventListener('click', () => this.toggle());
|
btn.addEventListener('click', () => this.toggle());
|
||||||
}
|
}
|
||||||
|
|
||||||
/* react to attribute changes or late target wiring */
|
attributeChangedCallback(name, _, newVal) {}
|
||||||
attributeChangedCallback(name, _, newVal) {
|
|
||||||
//if (name === 'target') this.targetEl = document.querySelector(newVal);
|
get targetEl() {
|
||||||
|
return document.querySelector("textarea") || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
get targetEl() { return document.querySelector("textarea") || null; }
|
connectedCallback() {}
|
||||||
|
|
||||||
|
|
||||||
connectedCallback() { // initial target lookup
|
get listening() {
|
||||||
// if (this.getAttribute('target'))
|
return this.hasAttribute('listening');
|
||||||
// this.targetEl = document.activeElement || null;
|
|
||||||
|
|
||||||
//document.querySelector(this.getAttribute('target'));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* property reflects listening status via [listening] attribute */
|
|
||||||
get listening() { return this.hasAttribute('listening'); }
|
|
||||||
set listening(val) {
|
set listening(val) {
|
||||||
if (val) this.setAttribute('listening','');
|
if (val) this.setAttribute('listening', '');
|
||||||
else this.removeAttribute('listening');
|
else this.removeAttribute('listening');
|
||||||
}
|
}
|
||||||
|
|
||||||
toggle() {
|
toggle() {
|
||||||
if (!this.recog) return;
|
if (!this.recog) return;
|
||||||
if (this.listening) { this.recog.stop(); this.listening = false; }
|
if (this.listening) {
|
||||||
else { this.recog.start(); this.listening = true; }
|
this.recog.stop();
|
||||||
|
this.listening = false;
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
this.recog.start();
|
||||||
|
this.listening = true;
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Error starting recognition:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Loading…
Reference in New Issue
Block a user