Justin Haaheim
Squashed commit of the following:
6bfe941
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<number>(y);
// We are reusing text blocks so this keeps track of when we changed rows so we can restart animation
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 + '|';
}
// 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<ThreeMeshUITextType>();
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 (
<>
<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(() => {
// 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<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} />
));
}
// Wait until more characters are accumulated if its just blankspace
if (/^\s*$/.test(newString)) {
transcriptState.lastRenderTime = currentTime;
return textBlocksProps.map((props, idx) => (
<TextBlock {...props} key={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) => (
<TextBlock {...props} key={idx} />
));
}