Speed up text editor for large inputs

This commit is contained in:
2026-02-07 22:45:05 +01:00
parent b7a680d7bb
commit d0342589ac
4 changed files with 43 additions and 57 deletions
+1
View File
@@ -3,6 +3,7 @@
- Split the big app file into smaller TypeScript modules and add `tsc` typechecking - Split the big app file into smaller TypeScript modules and add `tsc` typechecking
- Add PDF upload support (extract all text client-side for speed reading) - Add PDF upload support (extract all text client-side for speed reading)
- Improve text editor performance by applying changes explicitly instead of reprocessing on each keystroke - Improve text editor performance by applying changes explicitly instead of reprocessing on each keystroke
- Make the text editor use an uncontrolled textarea for better performance on large inputs
### 2026-02-02: 1.0.5 ### 2026-02-02: 1.0.5
+9 -24
View File
@@ -42,7 +42,6 @@ export default function App() {
const positionsRef = useRef<PositionsMap>(savedSettings?.positions || {}); const positionsRef = useRef<PositionsMap>(savedSettings?.positions || {});
const [text, setText] = useState(() => savedSettings?.text || DEFAULT_TEXT); const [text, setText] = useState(() => savedSettings?.text || DEFAULT_TEXT);
const [draftText, setDraftText] = useState(() => savedSettings?.text || DEFAULT_TEXT);
const [words, setWords] = useState(() => const [words, setWords] = useState(() =>
splitWords(savedSettings?.text || DEFAULT_TEXT), splitWords(savedSettings?.text || DEFAULT_TEXT),
); );
@@ -100,24 +99,15 @@ export default function App() {
const timeoutRef = useRef<number | null>(null); const timeoutRef = useRef<number | null>(null);
const prevTextRef = useRef(text); const prevTextRef = useRef(text);
const fileInputRef = useRef<HTMLInputElement | null>(null); const fileInputRef = useRef<HTMLInputElement | null>(null);
const [textEditorSession, setTextEditorSession] = useState(0);
const toggleTextInput = useCallback(() => { const toggleTextInput = useCallback(() => {
setShowTextInput((prev) => { setShowTextInput((prev) => {
const next = !prev; const next = !prev;
if (next) setDraftText(text); if (next) setTextEditorSession((s) => s + 1);
return next; return next;
}); });
}, [text]); }, []);
const applyDraftText = useCallback(() => {
setText(draftText);
setShowTextInput(false);
}, [draftText]);
const cancelDraftText = useCallback(() => {
setDraftText(text);
setShowTextInput(false);
}, [text]);
const togglePlay = useCallback(() => { const togglePlay = useCallback(() => {
if (currentIndex >= words.length - 1) { if (currentIndex >= words.length - 1) {
@@ -300,7 +290,6 @@ export default function App() {
case "Escape": case "Escape":
setShowInfo(false); setShowInfo(false);
setShowShortcuts(false); setShowShortcuts(false);
setDraftText(text);
setShowTextInput(false); setShowTextInput(false);
setShowSettings(false); setShowSettings(false);
break; break;
@@ -426,17 +415,13 @@ export default function App() {
{showTextInput && ( {showTextInput && (
<TextInputOverlay <TextInputOverlay
text={draftText} key={textEditorSession}
onChangeText={(e) => setDraftText(e.target.value)} initialText={text}
onKeyDown={(e) => { onApply={(nextText) => {
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) { setText(nextText);
e.preventDefault(); setShowTextInput(false);
applyDraftText();
}
}} }}
isDirty={draftText !== text} onCancel={() => setShowTextInput(false)}
onApply={applyDraftText}
onCancel={cancelDraftText}
/> />
)} )}
+33 -18
View File
@@ -1,30 +1,45 @@
import type { ChangeEventHandler, KeyboardEventHandler } from "react"; import { useCallback, useRef, useState } from "react";
import type { FormEventHandler, KeyboardEventHandler } from "react";
import { styles } from "../styles"; import { styles } from "../styles";
type Props = { type Props = {
text: string; initialText: string;
onChangeText: ChangeEventHandler<HTMLTextAreaElement>; onApply: (nextText: string) => void;
onKeyDown?: KeyboardEventHandler<HTMLTextAreaElement>;
isDirty: boolean;
onApply: () => void;
onCancel: () => void; onCancel: () => void;
}; };
export function TextInputOverlay({ export function TextInputOverlay({ initialText, onApply, onCancel }: Props) {
text, const textareaRef = useRef<HTMLTextAreaElement | null>(null);
onChangeText, const [isDirty, setIsDirty] = useState(false);
onKeyDown,
isDirty, const handleInput = useCallback<FormEventHandler<HTMLTextAreaElement>>(() => {
onApply, // Only flip once. Avoids re-rendering on every keystroke for huge texts.
onCancel, setIsDirty((prev) => prev || true);
}: Props) { }, []);
const apply = useCallback(() => {
const nextText = textareaRef.current?.value ?? initialText;
onApply(nextText);
}, [initialText, onApply]);
const handleKeyDown = useCallback<KeyboardEventHandler<HTMLTextAreaElement>>(
(e) => {
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) {
e.preventDefault();
apply();
}
},
[apply],
);
return ( return (
<div style={styles.textInputOverlay}> <div style={styles.textInputOverlay}>
<div style={styles.textInputPanel}> <div style={styles.textInputPanel}>
<textarea <textarea
value={text} defaultValue={initialText}
onChange={onChangeText} ref={textareaRef}
onKeyDown={onKeyDown} onInput={handleInput}
onKeyDown={handleKeyDown}
style={styles.textarea} style={styles.textarea}
placeholder="Paste your text here..." placeholder="Paste your text here..."
rows={8} rows={8}
@@ -45,7 +60,7 @@ export function TextInputOverlay({
Cancel Cancel
</button> </button>
<button <button
onClick={onApply} onClick={apply}
style={{ style={{
...styles.textInputBtnPrimary, ...styles.textInputBtnPrimary,
...(isDirty ? {} : styles.textInputBtnPrimaryDisabled), ...(isDirty ? {} : styles.textInputBtnPrimaryDisabled),
-15
View File
@@ -95,23 +95,8 @@ export function InfoModal({ onClose }: Props) {
<li>Focus on the red letter, let words come to you</li> <li>Focus on the red letter, let words come to you</li>
<li>Take breaks to avoid eye fatigue</li> <li>Take breaks to avoid eye fatigue</li>
</ul> </ul>
<h3 style={styles.sectionTitle}>Source code</h3>
<p style={styles.paragraph}>
This project is open source and available on{" "}
<a
href="https://github.com/ronilaukkarinen/speed-reader"
target="_blank"
rel="noopener noreferrer"
style={styles.link}
>
GitHub
</a>
.
</p>
</div> </div>
</div> </div>
</div> </div>
); );
} }