🪻 distributed transcription service thistle.dunkirk.sh
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: add copy button

+70 -2
+70 -2
src/components/vtt-viewer.ts
··· 74 74 @property({ type: String }) audioId = ""; 75 75 76 76 static override styles = css` 77 + .viewer-container { 78 + position: relative; 79 + } 80 + 81 + .copy-btn { 82 + position: absolute; 83 + top: 0.5rem; 84 + right: 0.5rem; 85 + background: var(--primary); 86 + color: var(--background); 87 + border: none; 88 + padding: 0.25rem 0.5rem; 89 + border-radius: 4px; 90 + font-size: 0.875rem; 91 + cursor: pointer; 92 + opacity: 0; 93 + transition: opacity 0.15s ease; 94 + } 95 + 96 + .viewer-container:hover .copy-btn { 97 + opacity: 1; 98 + } 99 + 77 100 .transcript { 78 101 background: color-mix(in srgb, var(--primary) 5%, transparent); 79 102 border-radius: 6px; ··· 143 166 if (!audioElement || !transcriptDiv) return; 144 167 145 168 // Clear any lingering highlights from prior instances 146 - transcriptDiv.querySelectorAll('.current-segment').forEach((el) => (el as HTMLElement).classList.remove('current-segment')); 169 + transcriptDiv.querySelectorAll('.current-segment').forEach((el) => { (el as HTMLElement).classList.remove('current-segment'); }); 147 170 148 171 this.audioElement = audioElement; 149 172 let currentSegmentElement: HTMLElement | null = null; ··· 262 285 return html`${paragraphs}`; 263 286 } 264 287 288 + private extractPlainText(): string { 289 + if (!this.vttContent) return ""; 290 + const segments = parseVTT(this.vttContent); 291 + // Group into paragraphs by index as in renderFromVTT 292 + const paragraphGroups = new Map<string, string[]>(); 293 + for (const s of segments) { 294 + const id = (s.index || "").trim(); 295 + const match = id.match(/^Paragraph\s+(\d+)-/); 296 + const paraNum = match ? match[1] : '0'; 297 + if (!paragraphGroups.has(paraNum)) paragraphGroups.set(paraNum, []); 298 + paragraphGroups.get(paraNum)!.push(s.text || ''); 299 + } 300 + const paragraphs = Array.from(paragraphGroups.values()).map(group => group.join(' ').replace(/\s+/g, ' ').trim()); 301 + return paragraphs.join('\n\n').trim(); 302 + } 303 + 304 + private async copyTranscript(e?: Event) { 305 + e && e.stopPropagation(); 306 + const text = this.extractPlainText(); 307 + if (!text) return; 308 + try { 309 + if (navigator && (navigator as any).clipboard && (navigator as any).clipboard.writeText) { 310 + await (navigator as any).clipboard.writeText(text); 311 + } else { 312 + // Fallback 313 + const ta = document.createElement('textarea'); 314 + ta.value = text; 315 + ta.style.position = 'fixed'; 316 + ta.style.opacity = '0'; 317 + document.body.appendChild(ta); 318 + ta.select(); 319 + document.execCommand('copy'); 320 + document.body.removeChild(ta); 321 + } 322 + const btn = this.shadowRoot?.querySelector('.copy-btn') as HTMLButtonElement | null; 323 + if (btn) { 324 + const orig = btn.innerText; 325 + btn.innerText = 'Copied!'; 326 + setTimeout(() => { btn.innerText = orig; }, 1500); 327 + } 328 + } catch { 329 + // ignore 330 + } 331 + } 332 + 265 333 override render() { 266 - return html`<div class="transcript">${this.renderFromVTT()}</div>`; 334 + return html`<div class="viewer-container"><button class="copy-btn" @click=${this.copyTranscript} aria-label="Copy transcript">Copy</button><div class="transcript">${this.renderFromVTT()}</div></div>`; 267 335 } 268 336 }