stackchain-dashboard/frontend/voice-issue-capture.js
timmy 3b4bb618dd
All checks were successful
CI / lint (pull_request) Successful in 2m14s
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Successful in 1m10s
CI / release-candidate (pull_request) Has been skipped
feat: add voice-to-draft mobile capture (Closes #929)
2026-08-16 02:02:24 +00:00

84 lines
3.5 KiB
JavaScript

function mapVoiceTranscript(value) {
const transcript = String(value || '').replace(/\s+/g, ' ').trim();
if (!transcript) return {title:'', body:''};
const sentence = transcript.match(/^(.+?[.!?])(?:\s+|$)(.*)$/);
const title = (sentence ? sentence[1] : transcript).trim().slice(0, 255);
const body = (sentence ? sentence[2] : '').trim().slice(0, 10000);
return {title, body};
}
function createVoiceIssueCapture({Recognition, elements, createEvent = name => new Event(name, {bubbles:true})}) {
const supported = typeof Recognition === 'function';
elements.root.hidden = !supported;
if (!supported) {
elements.status.textContent = 'Voice capture is unavailable; type the title and note instead.';
return {supported:false, cancel() {}};
}
let recognition = null;
function showReview(value) {
elements.transcript.value = value;
const hasDraft = Boolean(elements.title.value.trim() || elements.body.value.trim());
elements.append.textContent = hasDraft ? 'Append to draft' : 'Use transcript';
elements.replace.hidden = !hasDraft;
elements.review.hidden = false;
elements.status.textContent = 'Transcript ready. Review it before using it.';
}
elements.start.addEventListener('click', () => {
recognition = new Recognition();
recognition.continuous = true;
recognition.interimResults = false;
recognition.onresult = event => {
const finalText = Array.from(event.results || [])
.filter(result => result.isFinal)
.map(result => result[0]?.transcript || '').join(' ').replace(/\s+/g, ' ').trim();
if (finalText) showReview(finalText);
};
recognition.onerror = event => {
elements.start.hidden = false;
elements.stop.hidden = true;
elements.status.textContent = event.error === 'not-allowed' || event.error === 'service-not-allowed' ?
'Microphone permission was denied. Your draft and transcript are unchanged.' :
'Voice capture stopped. Your draft and transcript are unchanged.';
};
recognition.onend = () => {
elements.start.hidden = false;
elements.stop.hidden = true;
};
recognition.start();
elements.start.hidden = true;
elements.stop.hidden = false;
elements.status.textContent = 'Listening… Tap stop when you are finished.';
});
elements.stop.addEventListener('click', () => {
recognition?.stop();
elements.status.textContent = 'Finishing transcript…';
});
function commit(mode) {
const mapped = mapVoiceTranscript(elements.transcript.value);
if (mode === 'replace') {
elements.title.value = mapped.title;
elements.body.value = mapped.body;
} else if (elements.title.value.trim()) {
elements.body.value = [elements.body.value.trim(), mapped.title, mapped.body]
.filter(Boolean).join('\n\n').slice(0, 10000);
} else {
elements.title.value = mapped.title;
elements.body.value = [elements.body.value.trim(), mapped.body]
.filter(Boolean).join('\n\n').slice(0, 10000);
}
elements.title.dispatchEvent(createEvent('input'));
elements.body.dispatchEvent(createEvent('input'));
elements.review.hidden = true;
elements.status.textContent = 'Transcript added. Review the title and note before saving.';
}
elements.append.addEventListener('click', () => commit('append'));
elements.replace.addEventListener('click', () => commit('replace'));
return {supported:true, cancel() { recognition?.abort(); recognition = null; }};
}
if (typeof module !== 'undefined') module.exports = {mapVoiceTranscript, createVoiceIssueCapture};