|
import {useEffect, useRef, useState} from 'react'; |
|
import robotoFontFamilyJson from '../assets/RobotoMono-Regular-msdf.json?url'; |
|
import robotoFontTexture from '../assets/RobotoMono-Regular.png'; |
|
import ThreeMeshUIText, {ThreeMeshUITextType} from './ThreeMeshUIText'; |
|
import supportedCharSet from './supportedCharSet'; |
|
|
|
const NUM_LINES = 3; |
|
|
|
export const CHARS_PER_LINE = 37; |
|
const MAX_WIDTH = 0.89; |
|
const CHAR_WIDTH = 0.0235; |
|
const Y_COORD_START = -0.38; |
|
const Z_COORD = -1.3; |
|
const LINE_HEIGHT = 0.062; |
|
const BLOCK_SPACING = 0.02; |
|
const FONT_SIZE = 0.038; |
|
|
|
const SCROLL_Y_DELTA = 0.001; |
|
|
|
|
|
const OFFSET = 0.01; |
|
const OFFSET_WIDTH = OFFSET * 3; |
|
|
|
const CHARS_PER_SECOND = 10; |
|
|
|
|
|
const RENDER_INTERVAL = 300; |
|
|
|
const CURSOR_BLINK_INTERVAL_MS = 1000; |
|
|
|
type TextBlockProps = { |
|
content: string; |
|
|
|
y: number; |
|
|
|
startY: number; |
|
textOpacity: number; |
|
backgroundOpacity: number; |
|
index: number; |
|
isBottomLine: boolean; |
|
|
|
}; |
|
|
|
type TranscriptState = { |
|
textBlocksProps: TextBlockProps[]; |
|
lastTranslationStringIndex: number; |
|
lastTranslationLineStartIndex: number; |
|
transcriptLines: string[]; |
|
lastRenderTime: number; |
|
}; |
|
|
|
function TextBlock({ |
|
content, |
|
y, |
|
startY, |
|
textOpacity, |
|
backgroundOpacity, |
|
index, |
|
isBottomLine, |
|
}: TextBlockProps) { |
|
const [scrollY, setScrollY] = useState<number>(y); |
|
|
|
const lastIndex = useRef<number>(index); |
|
useEffect(() => { |
|
if (index != lastIndex.current) { |
|
lastIndex.current = index; |
|
!isBottomLine && setScrollY(startY); |
|
} else if (scrollY < y) { |
|
setScrollY((prev) => prev + SCROLL_Y_DELTA); |
|
} |
|
}, [isBottomLine, index, scrollY, setScrollY, startY, y]); |
|
|
|
const [cursorBlinkOn, setCursorBlinkOn] = useState(false); |
|
useEffect(() => { |
|
if (isBottomLine) { |
|
const interval = setInterval(() => { |
|
setCursorBlinkOn((prev) => !prev); |
|
}, CURSOR_BLINK_INTERVAL_MS); |
|
|
|
return () => clearInterval(interval); |
|
} else { |
|
setCursorBlinkOn(false); |
|
} |
|
}, [isBottomLine]); |
|
|
|
const numChars = content.length; |
|
|
|
if (cursorBlinkOn) { |
|
content = content + '|'; |
|
} |
|
|
|
|
|
const width = |
|
(numChars + (isBottomLine ? 1.1 : 0) + (numChars < 10 ? 1 : 0)) * |
|
CHAR_WIDTH; |
|
|
|
const height = LINE_HEIGHT; |
|
|
|
|
|
const textRef = useRef<ThreeMeshUITextType>(); |
|
useEffect(() => { |
|
if (textRef.current != null) { |
|
textRef.current.set({content}); |
|
} |
|
}, [content, textRef, y, startY]); |
|
|
|
|
|
const xPosition = width / 2 - MAX_WIDTH / 2 + OFFSET_WIDTH; |
|
return ( |
|
<> |
|
<block |
|
args={[ |
|
{ |
|
backgroundOpacity, |
|
width: width + OFFSET_WIDTH, |
|
height: height, |
|
borderRadius: 0, |
|
}, |
|
]} |
|
position={[-OFFSET_WIDTH + xPosition, scrollY, Z_COORD]}></block> |
|
<block |
|
args={[{padding: 0, backgroundOpacity: 0, width, height}]} |
|
position={[xPosition, scrollY + OFFSET, Z_COORD]}> |
|
<block |
|
args={[ |
|
{ |
|
width, |
|
height, |
|
fontSize: FONT_SIZE, |
|
textAlign: 'left', |
|
backgroundOpacity: 0, |
|
// TODO: support more language charsets |
|
// This renders using MSDF format supported in WebGL. Renderable characters are defined in the "charset" json |
|
// Currently supports most default keyboard inputs but this would exclude many non latin charset based languages. |
|
// You can use https://msdf-bmfont.donmccurdy.com/ for easily generating these files |
|
// fontFamily: '/src/assets/Roboto-msdf.json', |
|
// fontTexture: '/src/assets/Roboto-msdf.png' |
|
fontFamily: robotoFontFamilyJson, |
|
fontTexture: robotoFontTexture, |
|
}, |
|
]}> |
|
<ThreeMeshUIText ref={textRef} content="" fontOpacity={textOpacity} /> |
|
</block> |
|
</block> |
|
</> |
|
); |
|
} |
|
|
|
function initialTextBlockProps(count: number): TextBlockProps[] { |
|
return Array.from({length: count}).map(() => { |
|
|
|
return { |
|
y: Y_COORD_START, |
|
startY: 0, |
|
index: 0, |
|
textOpacity: 0, |
|
backgroundOpacity: 0, |
|
width: MAX_WIDTH, |
|
height: LINE_HEIGHT, |
|
content: '', |
|
isBottomLine: true, |
|
}; |
|
}); |
|
} |
|
|
|
export default function TextBlocks({ |
|
translationText, |
|
}: { |
|
translationText: string; |
|
}) { |
|
const transcriptStateRef = useRef<TranscriptState>({ |
|
textBlocksProps: initialTextBlockProps(NUM_LINES), |
|
lastTranslationStringIndex: 0, |
|
lastTranslationLineStartIndex: 0, |
|
transcriptLines: [], |
|
lastRenderTime: new Date().getTime(), |
|
}); |
|
|
|
const transcriptState = transcriptStateRef.current; |
|
const {textBlocksProps, lastTranslationStringIndex, lastRenderTime} = |
|
transcriptState; |
|
|
|
const [charsToRender, setCharsToRender] = useState<number>(0); |
|
|
|
useEffect(() => { |
|
const interval = setInterval(() => { |
|
const currentTime = new Date().getTime(); |
|
const charsToRender = Math.round( |
|
((currentTime - lastRenderTime) * CHARS_PER_SECOND) / 1000, |
|
); |
|
setCharsToRender(charsToRender); |
|
}, RENDER_INTERVAL); |
|
|
|
return () => clearInterval(interval); |
|
}, [lastRenderTime]); |
|
|
|
const currentTime = new Date().getTime(); |
|
if (charsToRender < 1) { |
|
return textBlocksProps.map((props, idx) => ( |
|
<TextBlock {...props} key={idx} /> |
|
)); |
|
} |
|
|
|
const nextTranslationStringIndex = Math.min( |
|
lastTranslationStringIndex + charsToRender, |
|
translationText.length, |
|
); |
|
const newString = translationText.substring( |
|
lastTranslationStringIndex, |
|
nextTranslationStringIndex, |
|
); |
|
if (nextTranslationStringIndex === lastTranslationStringIndex) { |
|
transcriptState.lastRenderTime = currentTime; |
|
return textBlocksProps.map((props, idx) => ( |
|
<TextBlock {...props} key={idx} /> |
|
)); |
|
} |
|
|
|
|
|
if (/^\s*$/.test(newString)) { |
|
transcriptState.lastRenderTime = currentTime; |
|
return textBlocksProps.map((props, idx) => ( |
|
<TextBlock {...props} key={idx} /> |
|
)); |
|
} |
|
|
|
|
|
const runAll = true; |
|
const newSentences = runAll |
|
? translationText.substring(0, nextTranslationStringIndex).split('\n') |
|
: newString.split('\n'); |
|
const transcriptLines = runAll ? [''] : transcriptState.transcriptLines; |
|
newSentences.forEach((newSentence, sentenceIdx) => { |
|
const words = newSentence.split(/\s+/); |
|
words.forEach((word) => { |
|
const filteredWord = [...word] |
|
.filter((c) => { |
|
if (supportedCharSet().has(c)) { |
|
return true; |
|
} |
|
console.error( |
|
`Unsupported char ${c} - make sure this is supported in the font family msdf file`, |
|
); |
|
return false; |
|
}) |
|
.join(''); |
|
|
|
const lastLineSoFar = transcriptLines[0]; |
|
const charCount = lastLineSoFar.length + filteredWord.length + 1; |
|
|
|
if (charCount <= CHARS_PER_LINE) { |
|
transcriptLines[0] = lastLineSoFar + ' ' + filteredWord; |
|
} else { |
|
transcriptLines.unshift(filteredWord); |
|
} |
|
}); |
|
|
|
if (sentenceIdx < newSentences.length - 1) { |
|
transcriptLines.unshift('\n'); |
|
transcriptLines.unshift(''); |
|
} |
|
}); |
|
|
|
transcriptState.transcriptLines = transcriptLines; |
|
transcriptState.lastTranslationStringIndex = nextTranslationStringIndex; |
|
|
|
const newTextBlocksProps: TextBlockProps[] = []; |
|
let currentY = Y_COORD_START; |
|
|
|
transcriptLines.forEach((line, i) => { |
|
if (newTextBlocksProps.length == NUM_LINES) { |
|
return; |
|
} |
|
|
|
|
|
if (line === '\n') { |
|
currentY += BLOCK_SPACING; |
|
return; |
|
} |
|
const y = currentY + LINE_HEIGHT / 2; |
|
const isBottomLine = newTextBlocksProps.length === 0; |
|
|
|
const textOpacity = 1 - 0.1 * newTextBlocksProps.length; |
|
newTextBlocksProps.push({ |
|
y, |
|
startY: currentY, |
|
index: i, |
|
textOpacity, |
|
backgroundOpacity: 0.98, |
|
content: line, |
|
isBottomLine, |
|
}); |
|
|
|
currentY = y + LINE_HEIGHT / 2; |
|
}); |
|
|
|
const numRemainingBlocks = NUM_LINES - newTextBlocksProps.length; |
|
if (numRemainingBlocks > 0) { |
|
newTextBlocksProps.push(...initialTextBlockProps(numRemainingBlocks)); |
|
} |
|
|
|
transcriptState.textBlocksProps = newTextBlocksProps; |
|
transcriptState.lastRenderTime = currentTime; |
|
return newTextBlocksProps.map((props, idx) => ( |
|
<TextBlock {...props} key={idx} /> |
|
)); |
|
} |
|
|