Improve text input performance with apply/cancel
This commit is contained in:
@@ -2,6 +2,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
|
||||||
|
|
||||||
### 2026-02-02: 1.0.5
|
### 2026-02-02: 1.0.5
|
||||||
|
|
||||||
|
|||||||
+32
-3
@@ -42,6 +42,7 @@ 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,6 +101,24 @@ export default function App() {
|
|||||||
const prevTextRef = useRef(text);
|
const prevTextRef = useRef(text);
|
||||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||||
|
|
||||||
|
const toggleTextInput = useCallback(() => {
|
||||||
|
setShowTextInput((prev) => {
|
||||||
|
const next = !prev;
|
||||||
|
if (next) setDraftText(text);
|
||||||
|
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) {
|
||||||
setCurrentIndex(0);
|
setCurrentIndex(0);
|
||||||
@@ -281,6 +300,7 @@ 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;
|
||||||
@@ -389,7 +409,7 @@ export default function App() {
|
|||||||
<div style={styles.container}>
|
<div style={styles.container}>
|
||||||
<TopBar
|
<TopBar
|
||||||
showTextInput={showTextInput}
|
showTextInput={showTextInput}
|
||||||
onToggleTextInput={() => setShowTextInput((prev) => !prev)}
|
onToggleTextInput={toggleTextInput}
|
||||||
fileInputRef={fileInputRef}
|
fileInputRef={fileInputRef}
|
||||||
onFileUpload={handleFileUpload}
|
onFileUpload={handleFileUpload}
|
||||||
fileAccept=".epub,.pdf,.txt"
|
fileAccept=".epub,.pdf,.txt"
|
||||||
@@ -406,8 +426,17 @@ export default function App() {
|
|||||||
|
|
||||||
{showTextInput && (
|
{showTextInput && (
|
||||||
<TextInputOverlay
|
<TextInputOverlay
|
||||||
text={text}
|
text={draftText}
|
||||||
onChangeText={(e) => setText(e.target.value)}
|
onChangeText={(e) => setDraftText(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) {
|
||||||
|
e.preventDefault();
|
||||||
|
applyDraftText();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
isDirty={draftText !== text}
|
||||||
|
onApply={applyDraftText}
|
||||||
|
onCancel={cancelDraftText}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -1,25 +1,65 @@
|
|||||||
import type { ChangeEventHandler } from "react";
|
import type { ChangeEventHandler, KeyboardEventHandler } from "react";
|
||||||
import { styles } from "../styles";
|
import { styles } from "../styles";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
text: string;
|
text: string;
|
||||||
onChangeText: ChangeEventHandler<HTMLTextAreaElement>;
|
onChangeText: ChangeEventHandler<HTMLTextAreaElement>;
|
||||||
|
onKeyDown?: KeyboardEventHandler<HTMLTextAreaElement>;
|
||||||
|
isDirty: boolean;
|
||||||
|
onApply: () => void;
|
||||||
|
onCancel: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function TextInputOverlay({ text, onChangeText }: Props) {
|
export function TextInputOverlay({
|
||||||
|
text,
|
||||||
|
onChangeText,
|
||||||
|
onKeyDown,
|
||||||
|
isDirty,
|
||||||
|
onApply,
|
||||||
|
onCancel,
|
||||||
|
}: Props) {
|
||||||
return (
|
return (
|
||||||
<div style={styles.textInputOverlay}>
|
<div style={styles.textInputOverlay}>
|
||||||
<div style={styles.textInputPanel}>
|
<div style={styles.textInputPanel}>
|
||||||
<textarea
|
<textarea
|
||||||
value={text}
|
value={text}
|
||||||
onChange={onChangeText}
|
onChange={onChangeText}
|
||||||
|
onKeyDown={onKeyDown}
|
||||||
style={styles.textarea}
|
style={styles.textarea}
|
||||||
placeholder="Paste your text here..."
|
placeholder="Paste your text here..."
|
||||||
rows={8}
|
rows={8}
|
||||||
autoFocus
|
autoFocus
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<div style={styles.textInputActions}>
|
||||||
|
<div style={styles.textInputHint}>
|
||||||
|
Changes won't affect the reader until you apply.
|
||||||
|
</div>
|
||||||
|
<div style={styles.textInputButtons}>
|
||||||
|
<button
|
||||||
|
onClick={onCancel}
|
||||||
|
style={styles.textInputBtnSecondary}
|
||||||
|
className="icon-btn"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={onApply}
|
||||||
|
style={{
|
||||||
|
...styles.textInputBtnPrimary,
|
||||||
|
...(isDirty ? {} : styles.textInputBtnPrimaryDisabled),
|
||||||
|
}}
|
||||||
|
className="icon-btn"
|
||||||
|
type="button"
|
||||||
|
disabled={!isDirty}
|
||||||
|
title={isDirty ? "Apply changes" : "No changes to apply"}
|
||||||
|
>
|
||||||
|
Apply
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -21,6 +21,7 @@ export function getWordDelay(word: string, baseDelayMs: number): number {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function formatReadingTime(minutes: number): string {
|
export function formatReadingTime(minutes: number): string {
|
||||||
|
minutes = Math.round(minutes);
|
||||||
if (minutes < 1) return "< 1 min";
|
if (minutes < 1) return "< 1 min";
|
||||||
if (minutes < 60) return `${Math.round(minutes)} min`;
|
if (minutes < 60) return `${Math.round(minutes)} min`;
|
||||||
const hours = Math.floor(minutes / 60);
|
const hours = Math.floor(minutes / 60);
|
||||||
@@ -28,4 +29,3 @@ export function formatReadingTime(minutes: number): string {
|
|||||||
if (mins === 0) return `${hours}h`;
|
if (mins === 0) return `${hours}h`;
|
||||||
return `${hours}h ${mins}m`;
|
return `${hours}h ${mins}m`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -117,6 +117,48 @@ export const styles: Record<string, CSSProperties> =
|
|||||||
padding: "4px",
|
padding: "4px",
|
||||||
border: "1px solid #222",
|
border: "1px solid #222",
|
||||||
},
|
},
|
||||||
|
textInputActions: {
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
gap: "12px",
|
||||||
|
padding: "10px 12px 12px 12px",
|
||||||
|
borderTop: "1px solid #1a1a1a",
|
||||||
|
},
|
||||||
|
textInputHint: {
|
||||||
|
fontSize: "0.75rem",
|
||||||
|
color: "#888",
|
||||||
|
lineHeight: 1.3,
|
||||||
|
},
|
||||||
|
textInputButtons: {
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "10px",
|
||||||
|
},
|
||||||
|
textInputBtnPrimary: {
|
||||||
|
backgroundColor: "#ff6b6b",
|
||||||
|
color: "#0a0a0a",
|
||||||
|
border: "none",
|
||||||
|
borderRadius: "8px",
|
||||||
|
padding: "10px 12px",
|
||||||
|
fontSize: "0.85rem",
|
||||||
|
fontWeight: 700,
|
||||||
|
cursor: "pointer",
|
||||||
|
},
|
||||||
|
textInputBtnPrimaryDisabled: {
|
||||||
|
opacity: 0.45,
|
||||||
|
cursor: "not-allowed",
|
||||||
|
},
|
||||||
|
textInputBtnSecondary: {
|
||||||
|
backgroundColor: "transparent",
|
||||||
|
color: "#bbb",
|
||||||
|
border: "1px solid #2a2a2a",
|
||||||
|
borderRadius: "8px",
|
||||||
|
padding: "10px 12px",
|
||||||
|
fontSize: "0.85rem",
|
||||||
|
fontWeight: 600,
|
||||||
|
cursor: "pointer",
|
||||||
|
},
|
||||||
textarea: {
|
textarea: {
|
||||||
width: "100%",
|
width: "100%",
|
||||||
padding: "16px",
|
padding: "16px",
|
||||||
|
|||||||
Reference in New Issue
Block a user