Speed up text editor for large inputs
This commit is contained in:
@@ -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
@@ -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}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user