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; // Overlay an extra block for padding due to inflexibilities of native padding const OFFSET = 0.01; const OFFSET_WIDTH = OFFSET * 3; const CHARS_PER_SECOND = 10; // The tick interval const RENDER_INTERVAL = 300; const CURSOR_BLINK_INTERVAL_MS = 1000; type TextBlockProps = { content: string; // The actual position or end position when animating y: number; // The start position when animating startY: number; textOpacity: number; backgroundOpacity: number; index: number; isBottomLine: boolean; // key: number; }; 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(y); // We are reusing text blocks so this keeps track of when we changed rows so we can restart animation const lastIndex = useRef(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 + '|'; } // Accounting for potential cursor for block width (the +1) const width = (numChars + (isBottomLine ? 1.1 : 0) + (numChars < 10 ? 1 : 0)) * CHAR_WIDTH; const height = LINE_HEIGHT; // This is needed to update text content (doesn't work if we just update the content prop) const textRef = useRef(); useEffect(() => { if (textRef.current != null) { textRef.current.set({content}); } }, [content, textRef, y, startY]); // Width starts from 0 and goes 1/2 in each direction const xPosition = width / 2 - MAX_WIDTH / 2 + OFFSET_WIDTH; return ( <> ); } function initialTextBlockProps(count: number): TextBlockProps[] { return Array.from({length: count}).map(() => { // Push in non display blocks because mesh UI crashes if elements are add / removed from screen. 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({ 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(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) => ( )); } 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) => ( )); } // Wait until more characters are accumulated if its just blankspace if (/^\s*$/.test(newString)) { transcriptState.lastRenderTime = currentTime; return textBlocksProps.map((props, idx) => ( )); } // Ideally we continue where we left off but this is complicated when we have mid-words. Recalculating for now 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; } // const line = transcriptLines[i]; 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) => ( )); }