Skip to content

Instantly share code, notes, and snippets.

@getflourish
Created October 3, 2025 09:25
Show Gist options
  • Select an option

  • Save getflourish/5187557b9bee23d08f2d49e076a6e2ef to your computer and use it in GitHub Desktop.

Select an option

Save getflourish/5187557b9bee23d08f2d49e076a6e2ef to your computer and use it in GitHub Desktop.
import React from "react";
import { GoogleGenAI } from "@google/genai";
export default function CapitalizationDemo() {
const textareaRef = React.useRef(null);
const [apiKey, setApiKey] = React.useState("");
const [model, setModel] = React.useState("gemini-2.5-flash-lite");
const [text, setText] = React.useState("");
const [isProcessing, setIsProcessing] = React.useState(false);
const [error, setError] = React.useState("");
const ensureTrailingNewline = React.useCallback((value) => {
return value.endsWith("\n") ? value : value + "\n";
}, []);
React.useEffect(() => {
try {
const savedKey = window.localStorage.getItem("CAPITALIZER_API_KEY");
if (savedKey) setApiKey(savedKey);
} catch {}
}, []);
React.useEffect(() => {
try {
if (apiKey) {
window.localStorage.setItem("CAPITALIZER_API_KEY", apiKey);
}
} catch {}
}, [apiKey]);
function createClient() {
if (!apiKey) throw new Error("Missing API key");
return new GoogleGenAI({ apiKey });
}
async function capitalizeViaAI(input) {
const ai = createClient();
const systemInstruction =
"You are a precise capitalization and punctuation fixer. You receive lowercase or inconsistently capitalized sentences in German or English. Return the same content with correct capitalization and punctuation, preserving meaning and formatting as much as possible. Do not add new content.";
const response = await ai.models.generateContent({
model,
contents: input,
config: {
systemInstruction,
},
});
return response.text;
}
function getCaretInfo() {
const el = textareaRef.current;
if (!el) return { start: 0, end: 0 };
return { start: el.selectionStart, end: el.selectionEnd };
}
function setCaret(posStart, posEnd) {
const el = textareaRef.current;
if (!el) return;
el.focus();
el.setSelectionRange(posStart, posEnd);
}
function getLineIndexAtPosition(value, pos) {
const upToPos = value.slice(0, pos);
return upToPos.split("\n").length - 1;
}
function getLineBounds(value, lineIndex) {
let start = 0;
for (let i = 0, count = 0; i < value.length; i++) {
if (count === lineIndex) {
start = i;
break;
}
if (value[i] === "\n") count++;
}
// If target line is first line
if (lineIndex === 0) start = 0;
let end = value.indexOf("\n", start);
if (end === -1) end = value.length;
return { start, end };
}
async function processLineAtIndex(lineIndex) {
if (lineIndex < 0) return;
setError("");
setIsProcessing(true);
try {
const { start, end } = getLineBounds(text, lineIndex);
const originalLine = text.slice(start, end);
const fixed = await capitalizeViaAI(originalLine);
const needsNewline = text[end] !== "\n";
const replacement = needsNewline ? fixed + "\n" : fixed;
const newText =
text.slice(0, start) +
replacement +
text.slice(end + (needsNewline ? 0 : 0));
// Compute caret offset change based on where caret currently is
const { start: caretStart, end: caretEnd } = getCaretInfo();
const delta = replacement.length - (end - start);
setText(ensureTrailingNewline(newText));
// Restore caret at same logical place relative to the edited segment
const afterUpdate = () => {
// If caret was after the edited line end, shift by delta
if (caretStart >= end) {
setCaret(caretStart + delta, caretEnd + delta);
} else {
// Keep caret as close as possible to original position
setCaret(caretStart, caretEnd);
}
};
// Wait next tick for DOM to reflect new value
requestAnimationFrame(afterUpdate);
} catch (e) {
setError(e?.message || String(e));
} finally {
setIsProcessing(false);
}
}
function handleKeyDown(e) {
if (e.key !== "Enter") return;
// Let the newline be inserted, then process the previous line
const { start } = getCaretInfo();
const lineNow = getLineIndexAtPosition(text, start);
const previousLine = lineNow;
if (previousLine < 0) return;
// Defer processing until after textarea updates with new line break
setTimeout(() => {
processLineAtIndex(previousLine);
}, 0);
}
return (
<div style={{ display: "grid", gap: 12 }}>
<div style={{ display: "grid", gap: 8 }}>
<label style={{ fontWeight: 600 }}>
API Key
<input
type="password"
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
placeholder="Enter Google AI Studio API key"
style={{
width: "100%",
padding: "8px 10px",
border: "1px solid #ccc",
borderRadius: 6,
marginTop: 6,
fontFamily: "inherit",
fontSize: 14,
}}
/>
</label>
<label style={{ fontWeight: 600 }}>
Model
<select
value={model}
onChange={(e) => setModel(e.target.value)}
style={{
width: "100%",
padding: "8px 10px",
border: "1px solid #ccc",
borderRadius: 6,
marginTop: 6,
fontFamily: "inherit",
fontSize: 14,
background: "white",
}}
>
<option value="gemini-2.5-flash-lite">gemini-2.5-flash-lite</option>
<option value="gemini-2.5-flash">gemini-2.5-flash</option>
</select>
</label>
</div>
<textarea
ref={textareaRef}
value={text}
onChange={(e) => setText(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={
"Type in lowercase. Press Enter to auto-capitalize the previous line."
}
rows={10}
style={{
width: "100%",
resize: "vertical",
padding: 12,
border: "1px solid #ccc",
borderRadius: 8,
fontFamily:
"ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace",
fontSize: 14,
lineHeight: 1.5,
}}
/>
{error ? (
<div style={{ color: "#b00020", fontSize: 14 }}>{error}</div>
) : null}
<div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}></div>
<div style={{ color: "#666", fontSize: 12 }}>
Press Enter to auto-capitalize the previous line. Your API key is stored
locally in localStorage.
</div>
</div>
);
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment