feat: cancel button, question prompt mic, solid timer (#10)
## Changes ### Cancel button (✕) - New cancel button appears during recording next to stop button - Tapping ✕ stops recording and **discards audio** — no text inserted - Tapping ⏹ stops recording and **inserts text** (as before) ### Question prompt support - Mic button injected into `[data-slot=question-custom-input]` (custom answer textarea) - Text insertion via `selectionStart`/`selectionEnd` (textarea ≠ contenteditable) - MutationObserver detects when question prompt appears ### Solid timer - Timer background: `#e53935` (opaque red) with white text - Was `rgba(229, 57, 53, 0.1)` (semi-transparent — text showed through) - Now fully visible over any text ### Technical - Refactored UI to use CSS classes instead of single IDs (supports multiple buttons) - `insertText()` now takes `target: "composer" | "question"` - 47 tests, 100% line coverage Co-authored-by: opencode-agent <agent@slaid098.dev>
This commit is contained in:
parent
33f2f48ac9
commit
960acd8a7a
4 changed files with 252 additions and 92 deletions
47
src/index.ts
47
src/index.ts
|
|
@ -1,4 +1,4 @@
|
|||
import { type AudioRecorder, createAudioRecorder, formatTime } from "./audio.js";
|
||||
import { type AudioRecorder, createAudioRecorder } from "./audio.js";
|
||||
import {
|
||||
getConfig,
|
||||
isFirstRun,
|
||||
|
|
@ -6,7 +6,7 @@ import {
|
|||
setConfig,
|
||||
validateApiKey,
|
||||
} from "./config.js";
|
||||
import { insertText, isOpencodePage, submitPrompt } from "./insert.js";
|
||||
import { type InsertTarget, insertText, isOpencodePage, submitPrompt } from "./insert.js";
|
||||
import { setupKeyboardShortcut } from "./keyboard.js";
|
||||
import { transcribe } from "./transcribe.js";
|
||||
import type { DictationState } from "./types.js";
|
||||
|
|
@ -17,10 +17,7 @@ let currentState: DictationState = "idle";
|
|||
let timerInterval: ReturnType<typeof setInterval> | null = null;
|
||||
let elapsedSeconds = 0;
|
||||
let ui: ReturnType<typeof setupUI> | null = null;
|
||||
|
||||
function getState(): DictationState {
|
||||
return currentState;
|
||||
}
|
||||
let currentTarget: InsertTarget = "composer";
|
||||
|
||||
function startTimer(): void {
|
||||
elapsedSeconds = 0;
|
||||
|
|
@ -37,7 +34,8 @@ function stopTimer(): void {
|
|||
}
|
||||
}
|
||||
|
||||
async function toggleDictation(): Promise<void> {
|
||||
async function toggleDictation(target: InsertTarget): Promise<void> {
|
||||
currentTarget = target;
|
||||
if (currentState === "idle") {
|
||||
await startRecording();
|
||||
} else if (currentState === "recording") {
|
||||
|
|
@ -75,10 +73,10 @@ async function stopAndTranscribe(): Promise<void> {
|
|||
const result = await transcribe(audioBlob, config);
|
||||
|
||||
if (result.text) {
|
||||
const inserted = insertText(result.text);
|
||||
const inserted = insertText(result.text, currentTarget);
|
||||
if (!inserted) {
|
||||
ui?.toast("Could not find input field", true);
|
||||
} else if (config.autoSubmit) {
|
||||
} else if (config.autoSubmit && currentTarget === "composer") {
|
||||
submitPrompt();
|
||||
}
|
||||
}
|
||||
|
|
@ -93,6 +91,26 @@ async function stopAndTranscribe(): Promise<void> {
|
|||
}
|
||||
}
|
||||
|
||||
async function cancelRecording(): Promise<void> {
|
||||
if (currentState !== "recording" || !recorder) {
|
||||
return;
|
||||
}
|
||||
|
||||
stopTimer();
|
||||
|
||||
try {
|
||||
await recorder.stop();
|
||||
} catch {
|
||||
// discard audio
|
||||
}
|
||||
|
||||
currentState = "idle";
|
||||
elapsedSeconds = 0;
|
||||
recorder = null;
|
||||
ui?.updateState(currentState);
|
||||
ui?.toast("Recording cancelled");
|
||||
}
|
||||
|
||||
function promptForApiKey(): void {
|
||||
const key = prompt("Enter your Groq API key (get one free at console.groq.com/keys):", "");
|
||||
if (key && validateApiKey(key)) {
|
||||
|
|
@ -155,10 +173,17 @@ function init(): void {
|
|||
return;
|
||||
}
|
||||
|
||||
ui = setupUI(toggleDictation, getState);
|
||||
ui = setupUI({
|
||||
onToggle: (target: InsertTarget) => {
|
||||
void toggleDictation(target);
|
||||
},
|
||||
onCancel: () => {
|
||||
void cancelRecording();
|
||||
},
|
||||
});
|
||||
|
||||
setupKeyboardShortcut(() => {
|
||||
void toggleDictation();
|
||||
void toggleDictation("composer");
|
||||
});
|
||||
|
||||
registerMenuCommands({
|
||||
|
|
|
|||
|
|
@ -1,11 +1,21 @@
|
|||
const PROMPT_INPUT_SELECTOR = '[data-component="prompt-input"]';
|
||||
const SUBMIT_SELECTOR = '[data-action="prompt-submit"]';
|
||||
const QUESTION_INPUT_SELECTOR = '[data-slot="question-custom-input"]';
|
||||
|
||||
export type InsertTarget = "composer" | "question";
|
||||
|
||||
export function isOpencodePage(): boolean {
|
||||
return document.querySelector(PROMPT_INPUT_SELECTOR) !== null;
|
||||
}
|
||||
|
||||
export function insertText(text: string): boolean {
|
||||
export function insertText(text: string, target: InsertTarget = "composer"): boolean {
|
||||
if (target === "question") {
|
||||
return insertIntoTextarea(text);
|
||||
}
|
||||
return insertIntoContenteditable(text);
|
||||
}
|
||||
|
||||
function insertIntoContenteditable(text: string): boolean {
|
||||
const input = document.querySelector<HTMLElement>(PROMPT_INPUT_SELECTOR);
|
||||
if (!input) {
|
||||
return false;
|
||||
|
|
@ -26,6 +36,24 @@ export function insertText(text: string): boolean {
|
|||
return true;
|
||||
}
|
||||
|
||||
function insertIntoTextarea(text: string): boolean {
|
||||
const textarea = document.querySelector<HTMLTextAreaElement>(QUESTION_INPUT_SELECTOR);
|
||||
if (!textarea) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const start = textarea.selectionStart;
|
||||
const end = textarea.selectionEnd;
|
||||
const newValue = textarea.value.substring(0, start) + text + textarea.value.substring(end);
|
||||
|
||||
textarea.value = newValue;
|
||||
textarea.selectionStart = textarea.selectionEnd = start + text.length;
|
||||
textarea.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
textarea.focus();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export function submitPrompt(): boolean {
|
||||
const submitButton = document.querySelector<HTMLElement>(SUBMIT_SELECTOR);
|
||||
if (!submitButton) {
|
||||
|
|
|
|||
189
src/ui.ts
189
src/ui.ts
|
|
@ -1,20 +1,24 @@
|
|||
import type { InsertTarget } from "./insert.js";
|
||||
import type { DictationState } from "./types.js";
|
||||
|
||||
const BUTTON_ID = "opencode-voice-dictation-btn";
|
||||
const CONTAINER_ID = "opencode-voice-dictation-container";
|
||||
const TIMER_ID = "opencode-voice-dictation-timer";
|
||||
const BUTTON_CLASS = "ocvd-btn";
|
||||
const CONTAINER_CLASS = "ocvd-container";
|
||||
const TIMER_CLASS = "ocvd-timer";
|
||||
const CANCEL_CLASS = "ocvd-cancel";
|
||||
const COMPOSER_SELECTORS = [
|
||||
'[data-component="session-composer"]',
|
||||
'[data-component="session-new-composer"]',
|
||||
];
|
||||
const QUESTION_INPUT_SELECTOR = '[data-slot="question-custom-input"]';
|
||||
|
||||
const ICON_MIC = `<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor"><path d="M12 14c1.66 0 3-1.34 3-3V5c0-1.66-1.34-3-3-3S9 3.34 9 5v6c0 1.66 1.34 3 3 3z"/><path d="M17 11c0 2.76-2.24 5-5 5s-5-2.24-5-5H5c0 3.53 2.61 6.43 6 6.92V21h2v-3.08c3.39-.49 6-3.39 6-6.92h-2z"/></svg>`;
|
||||
const ICON_STOP = `<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor"><rect x="6" y="6" width="12" height="12" rx="3"/></svg>`;
|
||||
const ICON_CANCEL = `<svg viewBox="0 0 24 24" width="14" height="14" fill="currentColor"><path d="M18.3 5.71L12 12l6.3 6.29-1.42 1.42L10.58 13.4 4.29 19.71 2.87 18.3 9.16 12 2.87 5.71 4.29 4.29 10.58 10.58l6.29-6.29z"/></svg>`;
|
||||
const ICON_SPINNER = `<svg class="ocvd-spin" viewBox="0 0 24 24" width="18" height="18" fill="currentColor"><path d="M12 4V1L8 5l4 4V6c3.31 0 6 2.69 6 6 0 1.01-.25 1.97-.7 2.8l1.46 1.46C19.54 15.03 20 13.57 20 12c0-4.42-3.58-8-8-8z"/></svg>`;
|
||||
|
||||
function createButtonStyle(): string {
|
||||
return `
|
||||
#${CONTAINER_ID} {
|
||||
.${CONTAINER_CLASS} {
|
||||
position: absolute !important;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
|
|
@ -24,10 +28,10 @@ function createButtonStyle(): string {
|
|||
gap: 6px;
|
||||
pointer-events: none;
|
||||
}
|
||||
#${CONTAINER_ID} > * {
|
||||
.${CONTAINER_CLASS} > * {
|
||||
pointer-events: auto;
|
||||
}
|
||||
#${BUTTON_ID} {
|
||||
.${BUTTON_CLASS} {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
|
@ -42,43 +46,65 @@ function createButtonStyle(): string {
|
|||
padding: 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
#${BUTTON_ID}:hover {
|
||||
.${BUTTON_CLASS}:hover {
|
||||
background: var(--color-bg-hover, rgba(128, 128, 128, 0.25));
|
||||
color: var(--color-text-primary, #fff);
|
||||
}
|
||||
#${BUTTON_ID}.recording {
|
||||
.${BUTTON_CLASS}.recording {
|
||||
background: #e53935;
|
||||
color: #fff;
|
||||
animation: ocvd-pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
#${BUTTON_ID}.processing {
|
||||
.${BUTTON_CLASS}.processing {
|
||||
background: var(--color-accent, #4a9eff);
|
||||
color: #fff;
|
||||
pointer-events: none;
|
||||
opacity: 0.8;
|
||||
}
|
||||
#${TIMER_ID} {
|
||||
.${CANCEL_CLASS} {
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
background: rgba(128, 128, 128, 0.2);
|
||||
color: var(--color-text-secondary, #aaa);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
padding: 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.${CANCEL_CLASS}:hover {
|
||||
background: rgba(128, 128, 128, 0.4);
|
||||
color: #fff;
|
||||
}
|
||||
.${CANCEL_CLASS}.visible {
|
||||
display: flex;
|
||||
}
|
||||
.${TIMER_CLASS} {
|
||||
display: none;
|
||||
font-family: monospace;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #e53935;
|
||||
background: rgba(229, 57, 53, 0.1);
|
||||
padding: 3px 10px;
|
||||
color: #fff;
|
||||
background: #e53935;
|
||||
padding: 4px 10px;
|
||||
border-radius: 12px;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
line-height: 1;
|
||||
white-space: nowrap;
|
||||
}
|
||||
#${TIMER_ID}.visible {
|
||||
.${TIMER_CLASS}.visible {
|
||||
display: inline-flex;
|
||||
}
|
||||
#${TIMER_ID}::before {
|
||||
.${TIMER_CLASS}::before {
|
||||
content: "";
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
background: #e53935;
|
||||
background: #fff;
|
||||
border-radius: 50%;
|
||||
animation: ocvd-blink 1s ease-in-out infinite;
|
||||
flex-shrink: 0;
|
||||
|
|
@ -150,34 +176,52 @@ function showToast(message: string, isError = false): void {
|
|||
}, 4000);
|
||||
}
|
||||
|
||||
function formatTimer(seconds: number): string {
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const secs = seconds % 60;
|
||||
return `${String(mins).padStart(2, "0")}:${String(secs).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function createContainer(): HTMLDivElement {
|
||||
const container = document.createElement("div");
|
||||
container.id = CONTAINER_ID;
|
||||
container.className = CONTAINER_CLASS;
|
||||
|
||||
const cancel = document.createElement("button");
|
||||
cancel.className = CANCEL_CLASS;
|
||||
cancel.type = "button";
|
||||
cancel.title = "Cancel recording";
|
||||
cancel.innerHTML = ICON_CANCEL;
|
||||
|
||||
const timer = document.createElement("span");
|
||||
timer.id = TIMER_ID;
|
||||
timer.className = TIMER_CLASS;
|
||||
timer.textContent = "00:00";
|
||||
|
||||
const button = document.createElement("button");
|
||||
button.id = BUTTON_ID;
|
||||
button.className = BUTTON_CLASS;
|
||||
button.type = "button";
|
||||
button.title = "Voice Dictation (Ctrl+Space)";
|
||||
button.innerHTML = ICON_MIC;
|
||||
|
||||
container.appendChild(cancel);
|
||||
container.appendChild(timer);
|
||||
container.appendChild(button);
|
||||
|
||||
return container;
|
||||
}
|
||||
|
||||
function updateButtonState(
|
||||
button: HTMLButtonElement,
|
||||
timer: HTMLElement,
|
||||
state: DictationState,
|
||||
elapsedSeconds = 0,
|
||||
): void {
|
||||
function updateAllButtonStates(state: DictationState, elapsedSeconds = 0): void {
|
||||
const containers = document.querySelectorAll<HTMLElement>(`.${CONTAINER_CLASS}`);
|
||||
|
||||
for (const container of containers) {
|
||||
const button = container.querySelector<HTMLElement>(`.${BUTTON_CLASS}`);
|
||||
const timer = container.querySelector<HTMLElement>(`.${TIMER_CLASS}`);
|
||||
const cancel = container.querySelector<HTMLElement>(`.${CANCEL_CLASS}`);
|
||||
|
||||
if (!button || !timer || !cancel) continue;
|
||||
|
||||
button.classList.remove("recording", "processing");
|
||||
timer.classList.remove("visible");
|
||||
cancel.classList.remove("visible");
|
||||
|
||||
switch (state) {
|
||||
case "idle":
|
||||
|
|
@ -190,6 +234,7 @@ function updateButtonState(
|
|||
button.title = "Stop recording";
|
||||
timer.textContent = formatTimer(elapsedSeconds);
|
||||
timer.classList.add("visible");
|
||||
cancel.classList.add("visible");
|
||||
break;
|
||||
case "processing":
|
||||
button.classList.add("processing");
|
||||
|
|
@ -198,11 +243,6 @@ function updateButtonState(
|
|||
break;
|
||||
}
|
||||
}
|
||||
|
||||
function formatTimer(seconds: number): string {
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const secs = seconds % 60;
|
||||
return `${String(mins).padStart(2, "0")}:${String(secs).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function findComposer(): HTMLElement | null {
|
||||
|
|
@ -215,48 +255,77 @@ function findComposer(): HTMLElement | null {
|
|||
return null;
|
||||
}
|
||||
|
||||
function isContainerInjected(): boolean {
|
||||
return document.getElementById(CONTAINER_ID) !== null;
|
||||
function ensureRelative(el: HTMLElement): void {
|
||||
if (window.getComputedStyle(el).position === "static") {
|
||||
el.style.position = "relative";
|
||||
}
|
||||
}
|
||||
|
||||
function injectButton(onToggle: () => void): void {
|
||||
if (isContainerInjected()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const composer = findComposer();
|
||||
if (!composer) {
|
||||
return;
|
||||
function injectIntoElement(
|
||||
parent: HTMLElement,
|
||||
onToggle: (target: InsertTarget) => void,
|
||||
onCancel: () => void,
|
||||
target: InsertTarget,
|
||||
): HTMLElement | null {
|
||||
const existing = parent.querySelector(`.${CONTAINER_CLASS}`);
|
||||
if (existing) {
|
||||
return null;
|
||||
}
|
||||
|
||||
injectStyles();
|
||||
|
||||
const computedPosition = window.getComputedStyle(composer).position;
|
||||
if (computedPosition === "static") {
|
||||
composer.style.position = "relative";
|
||||
}
|
||||
ensureRelative(parent);
|
||||
|
||||
const container = createContainer();
|
||||
const button = container.querySelector(`#${BUTTON_ID}`) as HTMLButtonElement;
|
||||
|
||||
const button = container.querySelector(`.${BUTTON_CLASS}`) as HTMLButtonElement;
|
||||
button.addEventListener("click", (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onToggle();
|
||||
onToggle(target);
|
||||
});
|
||||
|
||||
composer.appendChild(container);
|
||||
const cancel = container.querySelector(`.${CANCEL_CLASS}`) as HTMLButtonElement;
|
||||
cancel.addEventListener("click", (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onCancel();
|
||||
});
|
||||
|
||||
parent.appendChild(container);
|
||||
return container;
|
||||
}
|
||||
|
||||
export function setupUI(
|
||||
onToggle: () => void,
|
||||
_getState: () => DictationState,
|
||||
): {
|
||||
function injectIntoComposer(onToggle: (target: InsertTarget) => void, onCancel: () => void): void {
|
||||
const composer = findComposer();
|
||||
if (composer) {
|
||||
injectIntoElement(composer, onToggle, onCancel, "composer");
|
||||
}
|
||||
}
|
||||
|
||||
function injectIntoQuestionPrompts(
|
||||
onToggle: (target: InsertTarget) => void,
|
||||
onCancel: () => void,
|
||||
): void {
|
||||
const textareas = document.querySelectorAll<HTMLTextAreaElement>(QUESTION_INPUT_SELECTOR);
|
||||
for (const textarea of textareas) {
|
||||
const parent = textarea.parentElement;
|
||||
if (parent) {
|
||||
injectIntoElement(parent, onToggle, onCancel, "question");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function setupUI(callbacks: {
|
||||
onToggle: (target: InsertTarget) => void;
|
||||
onCancel: () => void;
|
||||
}): {
|
||||
inject: () => void;
|
||||
updateState: (state: DictationState, elapsedSeconds?: number) => void;
|
||||
toast: (message: string, isError?: boolean) => void;
|
||||
} {
|
||||
const observer = new MutationObserver(() => {
|
||||
injectButton(onToggle);
|
||||
injectIntoComposer(callbacks.onToggle, callbacks.onCancel);
|
||||
injectIntoQuestionPrompts(callbacks.onToggle, callbacks.onCancel);
|
||||
});
|
||||
|
||||
observer.observe(document.body, {
|
||||
|
|
@ -264,16 +333,16 @@ export function setupUI(
|
|||
subtree: true,
|
||||
});
|
||||
|
||||
injectButton(onToggle);
|
||||
injectIntoComposer(callbacks.onToggle, callbacks.onCancel);
|
||||
injectIntoQuestionPrompts(callbacks.onToggle, callbacks.onCancel);
|
||||
|
||||
return {
|
||||
inject: () => injectButton(onToggle),
|
||||
inject: () => {
|
||||
injectIntoComposer(callbacks.onToggle, callbacks.onCancel);
|
||||
injectIntoQuestionPrompts(callbacks.onToggle, callbacks.onCancel);
|
||||
},
|
||||
updateState: (state: DictationState, elapsedSeconds = 0) => {
|
||||
const button = document.getElementById(BUTTON_ID) as HTMLButtonElement | null;
|
||||
const timer = document.getElementById(TIMER_ID);
|
||||
if (button && timer) {
|
||||
updateButtonState(button, timer, state, elapsedSeconds);
|
||||
}
|
||||
updateAllButtonStates(state, elapsedSeconds);
|
||||
},
|
||||
toast: showToast,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -73,3 +73,41 @@ describe("submitPrompt", () => {
|
|||
expect(clicked).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("insertText into question textarea", () => {
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
it("should return false when question textarea not found", () => {
|
||||
expect(insertText("hello", "question")).toBe(false);
|
||||
});
|
||||
|
||||
it("should insert text into textarea at cursor position", () => {
|
||||
const textarea = document.createElement("textarea");
|
||||
textarea.setAttribute("data-slot", "question-custom-input");
|
||||
textarea.value = "hello world";
|
||||
textarea.selectionStart = 6;
|
||||
textarea.selectionEnd = 11;
|
||||
document.body.appendChild(textarea);
|
||||
|
||||
const result = insertText("there", "question");
|
||||
expect(result).toBe(true);
|
||||
expect(textarea.value).toBe("hello there");
|
||||
expect(textarea.selectionStart).toBe(11);
|
||||
});
|
||||
|
||||
it("should dispatch input event", () => {
|
||||
let eventCount = 0;
|
||||
const textarea = document.createElement("textarea");
|
||||
textarea.setAttribute("data-slot", "question-custom-input");
|
||||
textarea.value = "";
|
||||
textarea.addEventListener("input", () => {
|
||||
eventCount++;
|
||||
});
|
||||
document.body.appendChild(textarea);
|
||||
|
||||
insertText("test", "question");
|
||||
expect(eventCount).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue