import React from 'react'; import { CCBaseShapeUtil } from '../CCBaseShapeUtil'; import { TLShapeId } from '@tldraw/tldraw'; import { CCBaseShape } from '../cc-types'; import { TranscriptionManager } from '../cc-transcription/TranscriptionManager'; import { ccShapeProps, getDefaultCCLiveTranscriptionProps } from '../cc-props'; import { ccShapeMigrations } from '../cc-migrations'; import { CC_BASE_STYLE_CONSTANTS } from '../cc-styles'; export interface TranscriptionSegment { id: string text: string completed: boolean start: string end: string } export interface CCLiveTranscriptionShape extends CCBaseShape { type: 'cc-live-transcription' props: { title: string w: number h: number headerColor: string backgroundColor: string isLocked: boolean isRecording: boolean segments: TranscriptionSegment[] currentSegment?: TranscriptionSegment lastProcessedSegment?: string // Track last processed segment to avoid duplicates } } export class CCLiveTranscriptionShapeUtil extends CCBaseShapeUtil { static override type = 'cc-live-transcription' as const; static override props = ccShapeProps.liveTranscription; static override migrations = ccShapeMigrations.liveTranscription; override getDefaultProps(): CCLiveTranscriptionShape['props'] { return getDefaultCCLiveTranscriptionProps() as unknown as CCLiveTranscriptionShape['props']; } override renderContent = (shape: CCLiveTranscriptionShape) => { return this.renderShapeContent(shape) } renderShapeContent = (shape: CCLiveTranscriptionShape) => { const { isRecording, segments, currentSegment } = shape.props; const contentHeight = shape.props.h - CC_BASE_STYLE_CONSTANTS.HEADER.height - 2 * CC_BASE_STYLE_CONSTANTS.CONTENT.padding; const controlsHeight = 80; const transcriptHeight = contentHeight - controlsHeight; return (
{/* Microphone Controls */}
{ e.stopPropagation(); }} onClick={(e) => { e.stopPropagation(); this.toggleRecording(shape); }} style={{ width: '48px', height: '48px', borderRadius: '50%', border: 'none', backgroundColor: isRecording ? '#f44336' : '#4CAF50', color: 'white', fontSize: '24px', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center', boxShadow: '0 2px 4px rgba(0,0,0,0.2)', transition: 'all 0.3s ease', userSelect: 'none', }} > {isRecording ? '⏹' : '🎤'}
{isRecording ? 'Recording...' : 'Ready'}
{isRecording ? 'Click to stop' : 'Click to start recording'}
{/* Transcription Content */} {/* Completed Segments */} {segments.map((segment) => (
{segment.start}s - {segment.end}s
{segment.text}
))} {/* Current Segment */} {currentSegment && (
{currentSegment.start}s - {currentSegment.end}s
{currentSegment.text}
)} {/* Initial State */} {!isRecording && segments.length === 0 && !currentSegment && (
Click the microphone to start
)}
) } private toggleRecording(shape: CCLiveTranscriptionShape) { console.log('🎤 Toggle recording clicked'); const { id } = shape; const { isRecording } = shape.props; console.log('Current state:', { id, isRecording }); // When starting new recording, preserve existing props but reset segments const newProps = !isRecording ? { ...shape.props, isRecording: true, segments: [], currentSegment: undefined, lastProcessedSegment: undefined, } : { ...shape.props, isRecording: false, }; this.editor.updateShape({ id, type: 'cc-live-transcription', props: newProps, }); const manager = TranscriptionManager.getManager(this.editor); console.log('Got transcription manager'); if (!isRecording) { console.log('Starting transcription...'); manager.startTranscription(id); } else { console.log('Stopping transcription...'); manager.stopTranscription(); } } updateText( id: TLShapeId, text: string, isConfirmed: boolean, metadata?: { start: string | number, end: string | number } ) { console.log('📝 Updating text:', { id, text, isConfirmed, metadata }); const shape = this.editor.getShape(id); if (!shape) { console.warn('❌ Shape not found for updating text:', id); return; } // Format timestamps consistently const start = typeof metadata?.start === 'number' ? metadata.start.toFixed(3) : metadata?.start; const end = typeof metadata?.end === 'number' ? metadata.end.toFixed(3) : metadata?.end; const segmentId = isConfirmed ? crypto.randomUUID() : 'current'; const newSegment: TranscriptionSegment = { id: segmentId, text, completed: isConfirmed, start: start || '0.000', end: end || '0.000' }; // Handle current (incomplete) segment if (!isConfirmed) { // Only update if text has changed if (shape.props.currentSegment?.text !== text) { this.editor.updateShape({ id, type: 'cc-live-transcription', props: { ...shape.props, currentSegment: newSegment }, }); } return; } // Handle completed segment let segments = [...shape.props.segments]; // Check if this segment is different from the last processed one // and not already in our segments list const isDuplicate = segments.some(s => s.text === text); if (shape.props.lastProcessedSegment !== text && !isDuplicate) { // Add new completed segment segments.push(newSegment); this.editor.updateShape({ id, type: 'cc-live-transcription', props: { ...shape.props, segments, lastProcessedSegment: text, // Clear current segment if it matches the completed one currentSegment: shape.props.currentSegment?.text === text ? undefined : shape.props.currentSegment }, }); } console.log('✅ Text updated'); } } // Auto-scrolling container component function AutoScrollContainer({ children, height }: { children: React.ReactNode, height: number }) { const containerRef = React.useRef(null); React.useEffect(() => { if (containerRef.current) { containerRef.current.scrollTop = containerRef.current.scrollHeight; } }, [children]); return (
{children}
); }