342 lines
10 KiB
TypeScript
342 lines
10 KiB
TypeScript
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<CCLiveTranscriptionShape> {
|
|
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 (
|
|
<div style={{
|
|
width: '100%',
|
|
height: '100%',
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
pointerEvents: 'all'
|
|
}}>
|
|
{/* Microphone Controls */}
|
|
<div style={{
|
|
height: controlsHeight,
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
gap: '16px',
|
|
borderBottom: '1px solid #e0e0e0',
|
|
padding: '8px'
|
|
}}>
|
|
<div
|
|
role="button"
|
|
tabIndex={0}
|
|
onPointerDown={(e) => {
|
|
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 ? '⏹' : '🎤'}
|
|
</div>
|
|
<div style={{
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
alignItems: 'flex-start'
|
|
}}>
|
|
<div style={{
|
|
fontSize: '16px',
|
|
fontWeight: 'bold',
|
|
color: isRecording ? '#f44336' : '#4CAF50'
|
|
}}>
|
|
{isRecording ? 'Recording...' : 'Ready'}
|
|
</div>
|
|
<div style={{ fontSize: '12px', color: '#666' }}>
|
|
{isRecording ? 'Click to stop' : 'Click to start recording'}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Transcription Content */}
|
|
<AutoScrollContainer height={transcriptHeight}>
|
|
{/* Completed Segments */}
|
|
{segments.map((segment) => (
|
|
<div
|
|
key={segment.id}
|
|
style={{
|
|
padding: '8px',
|
|
backgroundColor: '#FFF',
|
|
borderRadius: '8px',
|
|
fontSize: '14px',
|
|
lineHeight: '1.4',
|
|
color: '#000000',
|
|
whiteSpace: 'pre-wrap',
|
|
wordBreak: 'break-word',
|
|
width: '100%',
|
|
transition: 'color 0.3s ease',
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
gap: '2px'
|
|
}}
|
|
>
|
|
<div style={{
|
|
fontSize: '11px',
|
|
color: '#888',
|
|
fontFamily: 'monospace'
|
|
}}>
|
|
{segment.start}s - {segment.end}s
|
|
</div>
|
|
<div>
|
|
{segment.text}
|
|
</div>
|
|
</div>
|
|
))}
|
|
|
|
{/* Current Segment */}
|
|
{currentSegment && (
|
|
<div
|
|
style={{
|
|
padding: '8px',
|
|
backgroundColor: '#f5f5f5',
|
|
borderRadius: '8px',
|
|
fontSize: '14px',
|
|
lineHeight: '1.4',
|
|
color: '#666666',
|
|
whiteSpace: 'pre-wrap',
|
|
wordBreak: 'break-word',
|
|
width: '100%',
|
|
transition: 'color 0.3s ease',
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
gap: '2px'
|
|
}}
|
|
>
|
|
<div style={{
|
|
fontSize: '11px',
|
|
color: '#888',
|
|
fontFamily: 'monospace'
|
|
}}>
|
|
{currentSegment.start}s - {currentSegment.end}s
|
|
</div>
|
|
<div>
|
|
{currentSegment.text}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Initial State */}
|
|
{!isRecording && segments.length === 0 && !currentSegment && (
|
|
<div
|
|
style={{
|
|
padding: '8px',
|
|
backgroundColor: '#FFF',
|
|
borderRadius: '8px',
|
|
fontSize: '18px',
|
|
lineHeight: '1.5',
|
|
color: '#666666',
|
|
whiteSpace: 'pre-wrap',
|
|
wordBreak: 'break-word',
|
|
width: '100%',
|
|
textAlign: 'center'
|
|
}}
|
|
>
|
|
Click the microphone to start
|
|
</div>
|
|
)}
|
|
</AutoScrollContainer>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
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<CCLiveTranscriptionShape>({
|
|
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<CCLiveTranscriptionShape>(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<CCLiveTranscriptionShape>({
|
|
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<CCLiveTranscriptionShape>({
|
|
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<HTMLDivElement>(null);
|
|
|
|
React.useEffect(() => {
|
|
if (containerRef.current) {
|
|
containerRef.current.scrollTop = containerRef.current.scrollHeight;
|
|
}
|
|
}, [children]);
|
|
|
|
return (
|
|
<div
|
|
ref={containerRef}
|
|
style={{
|
|
height,
|
|
padding: '16px',
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
justifyContent: 'flex-start',
|
|
gap: '8px',
|
|
overflow: 'auto',
|
|
scrollBehavior: 'smooth'
|
|
}}
|
|
>
|
|
{children}
|
|
</div>
|
|
);
|
|
}
|