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 {
|
import {
|
||||||
getConfig,
|
getConfig,
|
||||||
isFirstRun,
|
isFirstRun,
|
||||||
|
|
@ -6,7 +6,7 @@ import {
|
||||||
setConfig,
|
setConfig,
|
||||||
validateApiKey,
|
validateApiKey,
|
||||||
} from "./config.js";
|
} 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 { setupKeyboardShortcut } from "./keyboard.js";
|
||||||
import { transcribe } from "./transcribe.js";
|
import { transcribe } from "./transcribe.js";
|
||||||
import type { DictationState } from "./types.js";
|
import type { DictationState } from "./types.js";
|
||||||
|
|
@ -17,10 +17,7 @@ let currentState: DictationState = "idle";
|
||||||
let timerInterval: ReturnType<typeof setInterval> | null = null;
|
let timerInterval: ReturnType<typeof setInterval> | null = null;
|
||||||
let elapsedSeconds = 0;
|
let elapsedSeconds = 0;
|
||||||
let ui: ReturnType<typeof setupUI> | null = null;
|
let ui: ReturnType<typeof setupUI> | null = null;
|
||||||
|
let currentTarget: InsertTarget = "composer";
|
||||||
function getState(): DictationState {
|
|
||||||
return currentState;
|
|
||||||
}
|
|
||||||
|
|
||||||
function startTimer(): void {
|
function startTimer(): void {
|
||||||
elapsedSeconds = 0;
|
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") {
|
if (currentState === "idle") {
|
||||||
await startRecording();
|
await startRecording();
|
||||||
} else if (currentState === "recording") {
|
} else if (currentState === "recording") {
|
||||||
|
|
@ -75,10 +73,10 @@ async function stopAndTranscribe(): Promise<void> {
|
||||||
const result = await transcribe(audioBlob, config);
|
const result = await transcribe(audioBlob, config);
|
||||||
|
|
||||||
if (result.text) {
|
if (result.text) {
|
||||||
const inserted = insertText(result.text);
|
const inserted = insertText(result.text, currentTarget);
|
||||||
if (!inserted) {
|
if (!inserted) {
|
||||||
ui?.toast("Could not find input field", true);
|
ui?.toast("Could not find input field", true);
|
||||||
} else if (config.autoSubmit) {
|
} else if (config.autoSubmit && currentTarget === "composer") {
|
||||||
submitPrompt();
|
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 {
|
function promptForApiKey(): void {
|
||||||
const key = prompt("Enter your Groq API key (get one free at console.groq.com/keys):", "");
|
const key = prompt("Enter your Groq API key (get one free at console.groq.com/keys):", "");
|
||||||
if (key && validateApiKey(key)) {
|
if (key && validateApiKey(key)) {
|
||||||
|
|
@ -155,10 +173,17 @@ function init(): void {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
ui = setupUI(toggleDictation, getState);
|
ui = setupUI({
|
||||||
|
onToggle: (target: InsertTarget) => {
|
||||||
|
void toggleDictation(target);
|
||||||
|
},
|
||||||
|
onCancel: () => {
|
||||||
|
void cancelRecording();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
setupKeyboardShortcut(() => {
|
setupKeyboardShortcut(() => {
|
||||||
void toggleDictation();
|
void toggleDictation("composer");
|
||||||
});
|
});
|
||||||
|
|
||||||
registerMenuCommands({
|
registerMenuCommands({
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,21 @@
|
||||||
const PROMPT_INPUT_SELECTOR = '[data-component="prompt-input"]';
|
const PROMPT_INPUT_SELECTOR = '[data-component="prompt-input"]';
|
||||||
const SUBMIT_SELECTOR = '[data-action="prompt-submit"]';
|
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 {
|
export function isOpencodePage(): boolean {
|
||||||
return document.querySelector(PROMPT_INPUT_SELECTOR) !== null;
|
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);
|
const input = document.querySelector<HTMLElement>(PROMPT_INPUT_SELECTOR);
|
||||||
if (!input) {
|
if (!input) {
|
||||||
return false;
|
return false;
|
||||||
|
|
@ -26,6 +36,24 @@ export function insertText(text: string): boolean {
|
||||||
return true;
|
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 {
|
export function submitPrompt(): boolean {
|
||||||
const submitButton = document.querySelector<HTMLElement>(SUBMIT_SELECTOR);
|
const submitButton = document.querySelector<HTMLElement>(SUBMIT_SELECTOR);
|
||||||
if (!submitButton) {
|
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";
|
import type { DictationState } from "./types.js";
|
||||||
|
|
||||||
const BUTTON_ID = "opencode-voice-dictation-btn";
|
const BUTTON_CLASS = "ocvd-btn";
|
||||||
const CONTAINER_ID = "opencode-voice-dictation-container";
|
const CONTAINER_CLASS = "ocvd-container";
|
||||||
const TIMER_ID = "opencode-voice-dictation-timer";
|
const TIMER_CLASS = "ocvd-timer";
|
||||||
|
const CANCEL_CLASS = "ocvd-cancel";
|
||||||
const COMPOSER_SELECTORS = [
|
const COMPOSER_SELECTORS = [
|
||||||
'[data-component="session-composer"]',
|
'[data-component="session-composer"]',
|
||||||
'[data-component="session-new-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_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_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>`;
|
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 {
|
function createButtonStyle(): string {
|
||||||
return `
|
return `
|
||||||
#${CONTAINER_ID} {
|
.${CONTAINER_CLASS} {
|
||||||
position: absolute !important;
|
position: absolute !important;
|
||||||
top: 8px;
|
top: 8px;
|
||||||
right: 8px;
|
right: 8px;
|
||||||
|
|
@ -24,10 +28,10 @@ function createButtonStyle(): string {
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
#${CONTAINER_ID} > * {
|
.${CONTAINER_CLASS} > * {
|
||||||
pointer-events: auto;
|
pointer-events: auto;
|
||||||
}
|
}
|
||||||
#${BUTTON_ID} {
|
.${BUTTON_CLASS} {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
|
@ -42,43 +46,65 @@ function createButtonStyle(): string {
|
||||||
padding: 0;
|
padding: 0;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
#${BUTTON_ID}:hover {
|
.${BUTTON_CLASS}:hover {
|
||||||
background: var(--color-bg-hover, rgba(128, 128, 128, 0.25));
|
background: var(--color-bg-hover, rgba(128, 128, 128, 0.25));
|
||||||
color: var(--color-text-primary, #fff);
|
color: var(--color-text-primary, #fff);
|
||||||
}
|
}
|
||||||
#${BUTTON_ID}.recording {
|
.${BUTTON_CLASS}.recording {
|
||||||
background: #e53935;
|
background: #e53935;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
animation: ocvd-pulse 1.5s ease-in-out infinite;
|
animation: ocvd-pulse 1.5s ease-in-out infinite;
|
||||||
}
|
}
|
||||||
#${BUTTON_ID}.processing {
|
.${BUTTON_CLASS}.processing {
|
||||||
background: var(--color-accent, #4a9eff);
|
background: var(--color-accent, #4a9eff);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
opacity: 0.8;
|
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;
|
display: none;
|
||||||
font-family: monospace;
|
font-family: monospace;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #e53935;
|
color: #fff;
|
||||||
background: rgba(229, 57, 53, 0.1);
|
background: #e53935;
|
||||||
padding: 3px 10px;
|
padding: 4px 10px;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 5px;
|
gap: 5px;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
#${TIMER_ID}.visible {
|
.${TIMER_CLASS}.visible {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
}
|
}
|
||||||
#${TIMER_ID}::before {
|
.${TIMER_CLASS}::before {
|
||||||
content: "";
|
content: "";
|
||||||
width: 7px;
|
width: 7px;
|
||||||
height: 7px;
|
height: 7px;
|
||||||
background: #e53935;
|
background: #fff;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
animation: ocvd-blink 1s ease-in-out infinite;
|
animation: ocvd-blink 1s ease-in-out infinite;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
|
@ -150,34 +176,52 @@ function showToast(message: string, isError = false): void {
|
||||||
}, 4000);
|
}, 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 {
|
function createContainer(): HTMLDivElement {
|
||||||
const container = document.createElement("div");
|
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");
|
const timer = document.createElement("span");
|
||||||
timer.id = TIMER_ID;
|
timer.className = TIMER_CLASS;
|
||||||
timer.textContent = "00:00";
|
timer.textContent = "00:00";
|
||||||
|
|
||||||
const button = document.createElement("button");
|
const button = document.createElement("button");
|
||||||
button.id = BUTTON_ID;
|
button.className = BUTTON_CLASS;
|
||||||
button.type = "button";
|
button.type = "button";
|
||||||
button.title = "Voice Dictation (Ctrl+Space)";
|
button.title = "Voice Dictation (Ctrl+Space)";
|
||||||
button.innerHTML = ICON_MIC;
|
button.innerHTML = ICON_MIC;
|
||||||
|
|
||||||
|
container.appendChild(cancel);
|
||||||
container.appendChild(timer);
|
container.appendChild(timer);
|
||||||
container.appendChild(button);
|
container.appendChild(button);
|
||||||
|
|
||||||
return container;
|
return container;
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateButtonState(
|
function updateAllButtonStates(state: DictationState, elapsedSeconds = 0): void {
|
||||||
button: HTMLButtonElement,
|
const containers = document.querySelectorAll<HTMLElement>(`.${CONTAINER_CLASS}`);
|
||||||
timer: HTMLElement,
|
|
||||||
state: DictationState,
|
for (const container of containers) {
|
||||||
elapsedSeconds = 0,
|
const button = container.querySelector<HTMLElement>(`.${BUTTON_CLASS}`);
|
||||||
): void {
|
const timer = container.querySelector<HTMLElement>(`.${TIMER_CLASS}`);
|
||||||
|
const cancel = container.querySelector<HTMLElement>(`.${CANCEL_CLASS}`);
|
||||||
|
|
||||||
|
if (!button || !timer || !cancel) continue;
|
||||||
|
|
||||||
button.classList.remove("recording", "processing");
|
button.classList.remove("recording", "processing");
|
||||||
timer.classList.remove("visible");
|
timer.classList.remove("visible");
|
||||||
|
cancel.classList.remove("visible");
|
||||||
|
|
||||||
switch (state) {
|
switch (state) {
|
||||||
case "idle":
|
case "idle":
|
||||||
|
|
@ -190,6 +234,7 @@ function updateButtonState(
|
||||||
button.title = "Stop recording";
|
button.title = "Stop recording";
|
||||||
timer.textContent = formatTimer(elapsedSeconds);
|
timer.textContent = formatTimer(elapsedSeconds);
|
||||||
timer.classList.add("visible");
|
timer.classList.add("visible");
|
||||||
|
cancel.classList.add("visible");
|
||||||
break;
|
break;
|
||||||
case "processing":
|
case "processing":
|
||||||
button.classList.add("processing");
|
button.classList.add("processing");
|
||||||
|
|
@ -198,11 +243,6 @@ function updateButtonState(
|
||||||
break;
|
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 {
|
function findComposer(): HTMLElement | null {
|
||||||
|
|
@ -215,48 +255,77 @@ function findComposer(): HTMLElement | null {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function isContainerInjected(): boolean {
|
function ensureRelative(el: HTMLElement): void {
|
||||||
return document.getElementById(CONTAINER_ID) !== null;
|
if (window.getComputedStyle(el).position === "static") {
|
||||||
|
el.style.position = "relative";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function injectButton(onToggle: () => void): void {
|
function injectIntoElement(
|
||||||
if (isContainerInjected()) {
|
parent: HTMLElement,
|
||||||
return;
|
onToggle: (target: InsertTarget) => void,
|
||||||
}
|
onCancel: () => void,
|
||||||
|
target: InsertTarget,
|
||||||
const composer = findComposer();
|
): HTMLElement | null {
|
||||||
if (!composer) {
|
const existing = parent.querySelector(`.${CONTAINER_CLASS}`);
|
||||||
return;
|
if (existing) {
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
injectStyles();
|
injectStyles();
|
||||||
|
ensureRelative(parent);
|
||||||
const computedPosition = window.getComputedStyle(composer).position;
|
|
||||||
if (computedPosition === "static") {
|
|
||||||
composer.style.position = "relative";
|
|
||||||
}
|
|
||||||
|
|
||||||
const container = createContainer();
|
const container = createContainer();
|
||||||
const button = container.querySelector(`#${BUTTON_ID}`) as HTMLButtonElement;
|
|
||||||
|
const button = container.querySelector(`.${BUTTON_CLASS}`) as HTMLButtonElement;
|
||||||
button.addEventListener("click", (e) => {
|
button.addEventListener("click", (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
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(
|
function injectIntoComposer(onToggle: (target: InsertTarget) => void, onCancel: () => void): void {
|
||||||
onToggle: () => void,
|
const composer = findComposer();
|
||||||
_getState: () => DictationState,
|
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;
|
inject: () => void;
|
||||||
updateState: (state: DictationState, elapsedSeconds?: number) => void;
|
updateState: (state: DictationState, elapsedSeconds?: number) => void;
|
||||||
toast: (message: string, isError?: boolean) => void;
|
toast: (message: string, isError?: boolean) => void;
|
||||||
} {
|
} {
|
||||||
const observer = new MutationObserver(() => {
|
const observer = new MutationObserver(() => {
|
||||||
injectButton(onToggle);
|
injectIntoComposer(callbacks.onToggle, callbacks.onCancel);
|
||||||
|
injectIntoQuestionPrompts(callbacks.onToggle, callbacks.onCancel);
|
||||||
});
|
});
|
||||||
|
|
||||||
observer.observe(document.body, {
|
observer.observe(document.body, {
|
||||||
|
|
@ -264,16 +333,16 @@ export function setupUI(
|
||||||
subtree: true,
|
subtree: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
injectButton(onToggle);
|
injectIntoComposer(callbacks.onToggle, callbacks.onCancel);
|
||||||
|
injectIntoQuestionPrompts(callbacks.onToggle, callbacks.onCancel);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
inject: () => injectButton(onToggle),
|
inject: () => {
|
||||||
|
injectIntoComposer(callbacks.onToggle, callbacks.onCancel);
|
||||||
|
injectIntoQuestionPrompts(callbacks.onToggle, callbacks.onCancel);
|
||||||
|
},
|
||||||
updateState: (state: DictationState, elapsedSeconds = 0) => {
|
updateState: (state: DictationState, elapsedSeconds = 0) => {
|
||||||
const button = document.getElementById(BUTTON_ID) as HTMLButtonElement | null;
|
updateAllButtonStates(state, elapsedSeconds);
|
||||||
const timer = document.getElementById(TIMER_ID);
|
|
||||||
if (button && timer) {
|
|
||||||
updateButtonState(button, timer, state, elapsedSeconds);
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
toast: showToast,
|
toast: showToast,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -73,3 +73,41 @@ describe("submitPrompt", () => {
|
||||||
expect(clicked).toBe(true);
|
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