import { FolkShape } from "./folk-shape"; import { css, html } from "./tags"; import { SpeechDictation } from "./speech-dictation"; const styles = css` :host { background: white; border-radius: 8px; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); min-width: 350px; min-height: 400px; } .header { display: flex; align-items: center; justify-content: space-between; padding: 8px 12px; background: linear-gradient(135deg, #14b8a6, #06b6d4); color: white; border-radius: 8px 8px 0 0; font-size: 12px; font-weight: 600; cursor: move; } .header-title { display: flex; align-items: center; gap: 6px; } .header-actions { display: flex; gap: 4px; } .header-actions button { background: transparent; border: none; color: white; cursor: pointer; padding: 2px 6px; border-radius: 4px; font-size: 14px; } .header-actions button:hover { background: rgba(255, 255, 255, 0.2); } .content { display: flex; flex-direction: column; height: calc(100% - 36px); overflow: hidden; } .controls { display: flex; align-items: center; justify-content: center; gap: 16px; padding: 16px; border-bottom: 1px solid #e2e8f0; } .record-btn { width: 64px; height: 64px; border-radius: 50%; border: 4px solid #e2e8f0; background: white; cursor: pointer; display: flex; align-items: center; justify-content: center; transition: all 0.2s; } .record-btn:hover { border-color: #14b8a6; } .record-btn.recording { border-color: #ef4444; animation: pulse-ring 1.5s infinite; } .record-icon { width: 24px; height: 24px; border-radius: 50%; background: #ef4444; transition: all 0.2s; } .record-btn.recording .record-icon { border-radius: 4px; width: 20px; height: 20px; } @keyframes pulse-ring { 0% { box-shadow: 0 0 0 0 rgba(239, 68, 68, 0.4); } 70% { box-shadow: 0 0 0 10px rgba(239, 68, 68, 0); } 100% { box-shadow: 0 0 0 0 rgba(239, 68, 68, 0); } } .status { font-size: 12px; color: #64748b; } .status.recording { color: #ef4444; font-weight: 600; } .duration { font-family: "Monaco", "Consolas", monospace; font-size: 14px; color: #1e293b; } .transcript-area { flex: 1; overflow-y: auto; padding: 12px; } .placeholder { display: flex; flex-direction: column; align-items: center; justify-content: center; height: 100%; color: #94a3b8; text-align: center; gap: 8px; } .placeholder-icon { font-size: 48px; opacity: 0.5; } .transcript { font-size: 14px; line-height: 1.6; color: #1e293b; } .transcript-segment { margin-bottom: 12px; padding: 8px 12px; background: #f8fafc; border-radius: 8px; border-left: 3px solid #14b8a6; } .segment-time { font-size: 11px; color: #64748b; margin-bottom: 4px; font-family: "Monaco", "Consolas", monospace; } .segment-text { color: #1e293b; } .segment-text.interim { color: #94a3b8; font-style: italic; } .actions { display: flex; gap: 8px; padding: 12px; border-top: 1px solid #e2e8f0; } .action-btn { flex: 1; padding: 8px 12px; border: 2px solid #e2e8f0; border-radius: 6px; background: white; cursor: pointer; font-size: 12px; font-weight: 500; color: #64748b; transition: all 0.2s; } .action-btn:hover { border-color: #14b8a6; color: #14b8a6; } .error { color: #ef4444; padding: 12px; background: #fef2f2; border-radius: 6px; font-size: 13px; margin: 12px; } `; export interface TranscriptSegment { id: string; text: string; timestamp: number; isFinal: boolean; } declare global { interface HTMLElementTagNameMap { "folk-transcription": FolkTranscription; } } export class FolkTranscription extends FolkShape { static override tagName = "folk-transcription"; static { const sheet = new CSSStyleSheet(); const parentRules = Array.from(FolkShape.styles.cssRules) .map((r) => r.cssText) .join("\n"); const childRules = Array.from(styles.cssRules) .map((r) => r.cssText) .join("\n"); sheet.replaceSync(`${parentRules}\n${childRules}`); this.styles = sheet; } #segments: TranscriptSegment[] = []; #isRecording = false; #duration = 0; #durationInterval: ReturnType | null = null; #dictation: SpeechDictation | null = null; #error: string | null = null; #recordBtn: HTMLElement | null = null; #statusEl: HTMLElement | null = null; #durationEl: HTMLElement | null = null; #transcriptArea: HTMLElement | null = null; get segments() { return this.#segments; } get transcript() { return this.#segments .filter((s) => s.isFinal) .map((s) => s.text) .join(" "); } override createRenderRoot() { const root = super.createRenderRoot(); const wrapper = document.createElement("div"); wrapper.innerHTML = html`
🎤 Transcription
Ready to record
00:00
🎤 Click the record button to start Uses your browser's speech recognition
`; // Replace the container div (slot's parent) with our wrapper const slot = root.querySelector("slot"); const containerDiv = slot?.parentElement as HTMLElement; if (containerDiv) { containerDiv.replaceWith(wrapper); } this.#recordBtn = wrapper.querySelector(".record-btn"); this.#statusEl = wrapper.querySelector(".status"); this.#durationEl = wrapper.querySelector(".duration"); this.#transcriptArea = wrapper.querySelector(".transcript-area"); const copyBtn = wrapper.querySelector(".copy-btn") as HTMLButtonElement; const clearBtn = wrapper.querySelector(".clear-btn") as HTMLButtonElement; const closeBtn = wrapper.querySelector(".close-btn") as HTMLButtonElement; // Record button this.#recordBtn?.addEventListener("click", (e) => { e.stopPropagation(); this.#toggleRecording(); }); // Copy button copyBtn.addEventListener("click", (e) => { e.stopPropagation(); this.#copyTranscript(); }); // Clear button clearBtn.addEventListener("click", (e) => { e.stopPropagation(); this.#clearTranscript(); }); // Close button closeBtn.addEventListener("click", (e) => { e.stopPropagation(); this.#stopRecording(); this.dispatchEvent(new CustomEvent("close")); }); // Initialize speech dictation this.#initDictation(); return root; } #initDictation() { if (!SpeechDictation.isSupported()) { this.#error = "Speech recognition not supported in this browser"; this.#renderError(); return; } this.#dictation = new SpeechDictation({ onInterim: (text) => { const interimIdx = this.#segments.findIndex((s) => !s.isFinal); if (interimIdx >= 0) { this.#segments[interimIdx].text = text; } else { this.#segments.push({ id: crypto.randomUUID(), text, timestamp: this.#duration, isFinal: false, }); } this.#renderTranscript(); }, onFinal: (text) => { const interimIdx = this.#segments.findIndex((s) => !s.isFinal); if (interimIdx >= 0) { this.#segments[interimIdx] = { ...this.#segments[interimIdx], text, isFinal: true, }; } else { this.#segments.push({ id: crypto.randomUUID(), text, timestamp: this.#duration, isFinal: true, }); } this.#renderTranscript(); }, onError: (err) => { console.error("Speech recognition error:", err); this.#error = err; this.#renderError(); }, onStateChange: (recording) => { this.#isRecording = recording; if (recording) { this.#error = null; this.#recordBtn?.classList.add("recording"); if (this.#statusEl) { this.#statusEl.textContent = "Recording..."; this.#statusEl.classList.add("recording"); } this.#durationInterval = setInterval(() => { this.#duration++; this.#updateDuration(); }, 1000); this.dispatchEvent(new CustomEvent("recording-start")); } else { this.#recordBtn?.classList.remove("recording"); if (this.#statusEl) { this.#statusEl.textContent = "Stopped"; this.#statusEl.classList.remove("recording"); } if (this.#durationInterval) { clearInterval(this.#durationInterval); this.#durationInterval = null; } this.#segments = this.#segments.filter((s) => s.isFinal); this.#renderTranscript(); this.dispatchEvent(new CustomEvent("recording-stop", { detail: { transcript: this.transcript } })); } }, }); } #toggleRecording() { this.#dictation?.toggle(); } #stopRecording() { this.#dictation?.stop(); } #updateDuration() { if (!this.#durationEl) return; const mins = Math.floor(this.#duration / 60) .toString() .padStart(2, "0"); const secs = (this.#duration % 60).toString().padStart(2, "0"); this.#durationEl.textContent = `${mins}:${secs}`; } #renderTranscript() { if (!this.#transcriptArea) return; if (this.#segments.length === 0) { this.#transcriptArea.innerHTML = `
🎤 Click the record button to start Uses your browser's speech recognition
`; return; } this.#transcriptArea.innerHTML = this.#segments .map( (segment) => `
${this.#formatTime(segment.timestamp)}
${this.#escapeHtml(segment.text)}
` ) .join(""); // Scroll to bottom this.#transcriptArea.scrollTop = this.#transcriptArea.scrollHeight; } #renderError() { if (!this.#transcriptArea || !this.#error) return; this.#transcriptArea.innerHTML = `
${this.#escapeHtml(this.#error)}
`; } #formatTime(seconds: number): string { const mins = Math.floor(seconds / 60) .toString() .padStart(2, "0"); const secs = (seconds % 60).toString().padStart(2, "0"); return `${mins}:${secs}`; } async #copyTranscript() { try { await navigator.clipboard.writeText(this.transcript); this.dispatchEvent(new CustomEvent("copied")); } catch { console.error("Failed to copy transcript"); } } #clearTranscript() { this.#segments = []; this.#duration = 0; this.#updateDuration(); this.#renderTranscript(); } #escapeHtml(text: string): string { const div = document.createElement("div"); div.textContent = text; return div.innerHTML; } override toJSON() { return { ...super.toJSON(), type: "folk-transcription", transcript: this.transcript, segments: this.segments.map((s) => ({ ...s, })), }; } }