feat: add voice-to-draft mobile capture (Closes #929)
This commit is contained in:
1 parent
9adfcd01b6
commit
3b4bb618dd
9 files changed
+346
-4
No files matched your search
@@ -673,6 +673,12 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
.create-issue-header { display:flex; align-items:center; justify-content:space-between; gap:10px; }
|
||||
.create-issue-header button, .create-issue-actions button, .create-issue-capture-actions button { min-height:44px; }
|
||||
.create-issue-form { display:grid; gap:12px; }
|
||||
.voice-issue-capture { display:grid; gap:8px; min-width:0; padding:12px; border:1px solid #31577f; border-radius:12px; background:#101d31; }
|
||||
.voice-issue-capture[hidden], .voice-issue-review[hidden], .voice-issue-controls button[hidden] { display:none; }
|
||||
.voice-issue-heading, .voice-issue-review { display:grid; gap:6px; }
|
||||
.voice-issue-controls, .voice-issue-review-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:8px; }
|
||||
.voice-issue-controls button, .voice-issue-review-actions button { min-width:0; min-height:44px; }
|
||||
#voice-issue-transcript { box-sizing:border-box; width:100%; min-height:96px; resize:vertical; }
|
||||
.create-issue-capture-actions { position:sticky; bottom:0; display:grid; grid-template-columns:1fr 1fr; gap:8px; padding:10px 0; padding-bottom:calc(10px + env(safe-area-inset-bottom)); background:#0b1526; }
|
||||
.create-issue-capture-actions[hidden] { display:none; }
|
||||
.create-issue-filing { display:grid; gap:12px; min-width:0; }
|
||||
|
||||
@@ -735,12 +735,23 @@
|
||||
});
|
||||
let sharedLaunchState = null;
|
||||
let sharedLaunchHandled = false;
|
||||
let voiceIssueCapture = null;
|
||||
async function ensureIssueCapture() {
|
||||
return await issueCaptureFeatures.run('issue-capture', {
|
||||
trigger: qs('#new-issue'), status: qs('#my-work-action-status'),
|
||||
}, () => {
|
||||
if (!issueCapture) {
|
||||
issueCapture = createIssueCapture({ fetchJson: fetchReviewJson, storage: localStorage });
|
||||
voiceIssueCapture = createVoiceIssueCapture({
|
||||
Recognition: window.SpeechRecognition || window.webkitSpeechRecognition,
|
||||
elements: {
|
||||
root:qs('#voice-issue-capture'), start:qs('#start-voice-issue-capture'),
|
||||
stop:qs('#stop-voice-issue-capture'), review:qs('#voice-issue-review'),
|
||||
transcript:qs('#voice-issue-transcript'), append:qs('#append-voice-issue-transcript'),
|
||||
replace:qs('#replace-with-voice-issue-transcript'), status:qs('#voice-issue-status'),
|
||||
title:qs('#create-issue-title'), body:qs('#create-issue-body'),
|
||||
},
|
||||
});
|
||||
issueOwnerPicker = createIssueCapture.createOwnerPicker(issueCapture, document,
|
||||
() => { saveIssueCaptureDraft(); updateIssueCreateActions(); });
|
||||
issueTemplatePicker = issueCapture.bindTemplatePicker({
|
||||
@@ -4343,6 +4354,7 @@
|
||||
return;
|
||||
}
|
||||
qs('#create-issue-sheet').classList.remove('open');
|
||||
voiceIssueCapture.cancel();
|
||||
mobileComposerViewport.close(qs('.create-issue-panel'));
|
||||
clearTimeout(duplicateCheckTimer);
|
||||
qs('#create-issue-duplicates').hidden = true;
|
||||
|
||||
@@ -816,6 +816,26 @@
|
||||
<label for="create-issue-body">Note <span class="small">Optional</span>
|
||||
<textarea id="create-issue-body" maxlength="10000"></textarea>
|
||||
</label>
|
||||
<section class="voice-issue-capture" id="voice-issue-capture" aria-labelledby="voice-issue-heading" hidden>
|
||||
<div class="voice-issue-heading">
|
||||
<strong id="voice-issue-heading">Speak capture</strong>
|
||||
<span class="small">Your browser transcribes speech. Stackchain does not retain audio.</span>
|
||||
</div>
|
||||
<div class="voice-issue-controls">
|
||||
<button id="start-voice-issue-capture" type="button">Speak capture</button>
|
||||
<button id="stop-voice-issue-capture" type="button" hidden>Stop listening</button>
|
||||
</div>
|
||||
<div id="voice-issue-status" class="small" aria-live="polite"></div>
|
||||
<div class="voice-issue-review" id="voice-issue-review" hidden>
|
||||
<label for="voice-issue-transcript">Review transcript
|
||||
<textarea id="voice-issue-transcript" maxlength="10255"></textarea>
|
||||
</label>
|
||||
<div class="voice-issue-review-actions">
|
||||
<button id="append-voice-issue-transcript" type="button">Append to draft</button>
|
||||
<button id="replace-with-voice-issue-transcript" type="button">Replace draft</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<div class="create-issue-capture-actions">
|
||||
<button id="save-unfiled-issue" type="button">Save to Drafts</button>
|
||||
<button id="file-new-issue" type="button">File now</button>
|
||||
@@ -1349,6 +1369,7 @@
|
||||
<script src="static/issue-attachment.js"></script>
|
||||
<script src="static/issue-filing-review.js"></script>
|
||||
<script src="static/issue-sheet.js"></script>
|
||||
<script src="static/voice-issue-capture.js"></script>
|
||||
<script src="static/create-issue-sheet.js"></script>
|
||||
<script src="static/create-and-start.js"></script>
|
||||
<script src="static/assign-and-start.js"></script>
|
||||
|
||||
@@ -75,6 +75,7 @@ const SHELL = [
|
||||
BASE + 'static/issue-filing-review.js',
|
||||
BASE + 'static/issue-sheet.js',
|
||||
BASE + 'static/checklist-conflict.js',
|
||||
BASE + 'static/voice-issue-capture.js',
|
||||
BASE + 'static/create-issue-sheet.js',
|
||||
BASE + 'static/create-and-start.js',
|
||||
BASE + 'static/assign-and-start.js',
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
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};
|
||||
Reference in new issue
Block a user