diff --git a/app/flashcards/flashcards-client.test.tsx b/app/flashcards/flashcards-client.test.tsx index 4e7e271..036cdaa 100644 --- a/app/flashcards/flashcards-client.test.tsx +++ b/app/flashcards/flashcards-client.test.tsx @@ -77,11 +77,9 @@ describe("FlashcardsClient", () => { it("keeps My Flashcards focused on saved deck management", () => { render(); - expect(screen.getByRole("heading", { name: "My Flashcards" })).toBeInTheDocument(); - expect(screen.getByRole("heading", { name: "Biology deck" })).toBeInTheDocument(); - expect(screen.getByRole("button", { name: "Review" })).toBeInTheDocument(); - expect(screen.getByRole("button", { name: /Edit/ })).toBeInTheDocument(); - expect(screen.getByRole("button", { name: /Delete/ })).toBeInTheDocument(); + expect(screen.getByText("1 decks")).toBeInTheDocument(); + expect(screen.getByText("Biology deck")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /Open actions for Biology deck/ })).toBeInTheDocument(); expect(screen.queryByRole("checkbox", { name: /Lecture One/ })).not.toBeInTheDocument(); }); @@ -131,7 +129,7 @@ describe("FlashcardsClient", () => { await user.clear(screen.getByLabelText("Deck name")); await user.type(screen.getByLabelText("Deck name"), "Edited deck"); await user.clear(screen.getByLabelText("Front")); - await user.type(screen.getByLabelText("Front"), "Edited front"); + await user.type(screen.getByLabelText("Front"), "Edited front $√(x²)$"); await user.type(screen.getByLabelText("Tags"), "core"); await user.click(screen.getByRole("button", { name: /Save deck/ })); @@ -148,7 +146,7 @@ describe("FlashcardsClient", () => { title: "Edited deck", cards: [ { - front: "Edited front", + front: "Edited front $\\sqrt{x^2}$", back: "Generated back", tags: ["core"], }, @@ -175,7 +173,8 @@ describe("FlashcardsClient", () => { render(); - await user.click(screen.getByRole("button", { name: /Edit/ })); + await user.click(screen.getByRole("button", { name: /Open actions for Biology deck/ })); + await user.click(screen.getByRole("button", { name: "Edit" })); await user.clear(screen.getByLabelText("Deck name")); await user.type(screen.getByLabelText("Deck name"), "Renamed deck"); await user.clear(screen.getByLabelText("Back")); @@ -213,12 +212,13 @@ describe("FlashcardsClient", () => { render(); - await user.click(screen.getByRole("button", { name: /Delete/ })); + await user.click(screen.getByRole("button", { name: /Open actions for Biology deck/ })); + await user.click(screen.getByRole("button", { name: "Delete" })); await waitFor(() => { - expect(screen.queryByRole("heading", { name: "Biology deck" })).not.toBeInTheDocument(); + expect(screen.queryByText("Biology deck")).not.toBeInTheDocument(); }); - expect(screen.getByText("No flashcard decks")).toBeInTheDocument(); + expect(screen.getByText("No saved decks")).toBeInTheDocument(); expect(fetch).toHaveBeenCalledWith("/api/flashcards?deckId=deck-1", { method: "DELETE" }); }); }); diff --git a/app/flashcards/flashcards-client.tsx b/app/flashcards/flashcards-client.tsx index 1546dc8..ccc4f8a 100644 --- a/app/flashcards/flashcards-client.tsx +++ b/app/flashcards/flashcards-client.tsx @@ -10,7 +10,6 @@ import { Edit3, Loader2, MoreHorizontal, - Play, Plus, RotateCcw, Save, @@ -18,10 +17,7 @@ import { X, } from "lucide-react"; import { toast } from "sonner"; -import ReactMarkdown from "react-markdown"; -import rehypeKatex from "rehype-katex"; -import remarkGfm from "remark-gfm"; -import remarkMath from "remark-math"; +import { LatexMarkdown } from "@/components/math/latex-markdown"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { @@ -32,6 +28,7 @@ import { CardTitle, } from "@/components/ui/card"; import { Input, Textarea } from "@/components/ui/input"; +import { normalizeTextMathToLatex } from "@/lib/math/latex"; import { cx } from "@/lib/utils"; import type { FlashcardDeck, FlashcardItem } from "@/lib/flashcards/types"; @@ -89,6 +86,21 @@ function cloneDeck(deck: FlashcardDeck): FlashcardDeck { }; } +function normalizeCardMath(card: FlashcardItem): FlashcardItem { + return { + ...card, + front: normalizeTextMathToLatex(card.front), + back: normalizeTextMathToLatex(card.back), + }; +} + +function normalizeDeckMath(deck: FlashcardDeck): FlashcardDeck { + return { + ...deck, + cards: deck.cards.map(normalizeCardMath), + }; +} + function withCardCount(deck: FlashcardDeck): FlashcardDeck { return { ...deck, @@ -112,39 +124,6 @@ function parseTags(value: string) { .slice(0, 8); } -function MarkdownText({ - markdown, - className, -}: { - markdown: string; - className?: string; -}) { - return ( -
-

{children}

, - ul: ({ children }) => ( -
    {children}
- ), - ol: ({ children }) => ( -
    {children}
- ), - code: ({ children }) => ( - - {children} - - ), - }} - > - {markdown} -
-
- ); -} - function StatPill({ children }: { children: ReactNode }) { return ( @@ -252,6 +231,11 @@ function DeckEditor({ onChange={(event) => updateCard(index, { front: event.target.value }) } + onBlur={(event) => + updateCard(index, { + front: normalizeTextMathToLatex(event.target.value), + }) + } /> @@ -409,6 +398,7 @@ export function FlashcardsClient({ return; } + const normalizedDeck = normalizeDeckMath(draftDeck); setIsSaving(true); try { const payload = await readJsonResponse<{ deck: FlashcardDeck }>( @@ -417,9 +407,9 @@ export function FlashcardsClient({ headers: { "Content-Type": "application/json" }, body: JSON.stringify({ mode: "save", - title: draftDeck.title, - sourceNoteIds: draftDeck.sourceNoteIds, - cards: draftDeck.cards, + title: normalizedDeck.title, + sourceNoteIds: normalizedDeck.sourceNoteIds, + cards: normalizedDeck.cards, }), }), ); @@ -444,6 +434,7 @@ export function FlashcardsClient({ return; } + const normalizedDeck = normalizeDeckMath(editingDeck); setIsSaving(true); try { const payload = await readJsonResponse<{ deck: FlashcardDeck }>( @@ -451,10 +442,10 @@ export function FlashcardsClient({ method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ - deckId: editingDeck.id, - title: editingDeck.title, - sourceNoteIds: editingDeck.sourceNoteIds, - cards: editingDeck.cards, + deckId: normalizedDeck.id, + title: normalizedDeck.title, + sourceNoteIds: normalizedDeck.sourceNoteIds, + cards: normalizedDeck.cards, }), }), ); @@ -591,6 +582,7 @@ export function FlashcardsClient({ +
diff --git a/lib/math/latex.test.ts b/lib/math/latex.test.ts new file mode 100644 index 0000000..6a852a5 --- /dev/null +++ b/lib/math/latex.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from "vitest"; +import { normalizeLatex, normalizeTextMathToLatex } from "@/lib/math/latex"; + +describe("latex normalization", () => { + it("converts common typed math symbols to LaTeX", () => { + expect(normalizeLatex("√(x² + y₂) ≤ π")).toBe("\\sqrt{x^2 + y_2} \\le \\pi"); + }); + + it("normalizes only delimited math inside text", () => { + expect(normalizeTextMathToLatex("Use $√(x²)$ and then save.")).toBe( + "Use $\\sqrt{x^2}$ and then save.", + ); + }); + + it("normalizes display math delimiters", () => { + expect(normalizeTextMathToLatex("$$\nα + β ≥ γ\n$$")).toBe( + "$$\n\\alpha + \\beta \\ge \\gamma\n$$", + ); + }); +}); diff --git a/lib/math/latex.ts b/lib/math/latex.ts new file mode 100644 index 0000000..068e3d9 --- /dev/null +++ b/lib/math/latex.ts @@ -0,0 +1,110 @@ +const UNICODE_LATEX_REPLACEMENTS: Array<[RegExp, string]> = [ + [/≤/g, "\\le "], + [/≥/g, "\\ge "], + [/≠/g, "\\ne "], + [/≈/g, "\\approx "], + [/±/g, "\\pm "], + [/×/g, "\\times "], + [/÷/g, "\\div "], + [/∞/g, "\\infty "], + [/π/g, "\\pi "], + [/Π/g, "\\Pi "], + [/θ/g, "\\theta "], + [/Θ/g, "\\Theta "], + [/λ/g, "\\lambda "], + [/Λ/g, "\\Lambda "], + [/μ/g, "\\mu "], + [/σ/g, "\\sigma "], + [/Σ/g, "\\Sigma "], + [/Ω/g, "\\Omega "], + [/√\s*\(([^()\n]+)\)/g, "\\sqrt{$1}"], + [/√\s*([A-Za-z0-9]+)/g, "\\sqrt{$1}"], + [/∑/g, "\\sum "], + [/∫/g, "\\int "], + [/→/g, "\\to "], + [/⇒/g, "\\Rightarrow "], + [/∈/g, "\\in "], + [/∉/g, "\\notin "], + [/∂/g, "\\partial "], + [/∇/g, "\\nabla "], + [/∆/g, "\\Delta "], + [/α/g, "\\alpha "], + [/β/g, "\\beta "], + [/γ/g, "\\gamma "], + [/δ/g, "\\delta "], +]; + +const SUPERSCRIPT_DIGITS: Record = { + "⁰": "0", + "¹": "1", + "²": "2", + "³": "3", + "⁴": "4", + "⁵": "5", + "⁶": "6", + "⁷": "7", + "⁸": "8", + "⁹": "9", +}; + +const SUBSCRIPT_DIGITS: Record = { + "₀": "0", + "₁": "1", + "₂": "2", + "₃": "3", + "₄": "4", + "₅": "5", + "₆": "6", + "₇": "7", + "₈": "8", + "₉": "9", +}; + +function normalizeScriptDigits(value: string, digits: Record, marker: "^" | "_") { + const scriptChars = Object.keys(digits).join(""); + const pattern = new RegExp(`[${scriptChars}]+`, "g"); + + return value.replace(pattern, (match) => { + const normalized = [...match].map((char) => digits[char] ?? char).join(""); + return normalized.length === 1 ? `${marker}${normalized}` : `${marker}{${normalized}}`; + }); +} + +function normalizeLatexSegment(value: string) { + let normalized = value; + + for (const [pattern, replacement] of UNICODE_LATEX_REPLACEMENTS) { + normalized = normalized.replace(pattern, replacement); + } + + normalized = normalizeScriptDigits(normalized, SUPERSCRIPT_DIGITS, "^"); + normalized = normalizeScriptDigits(normalized, SUBSCRIPT_DIGITS, "_"); + normalized = normalized.replace(/\s+([_^])/g, "$1"); + normalized = normalized.replace(/([_^])\s+/g, "$1"); + normalized = normalized.replace(/[ \t]{2,}/g, " "); + + return normalized.trim(); +} + +function normalizeDelimitedMath(value: string) { + return value.replace(/(\${1,2})([\s\S]*?)(\1)/g, (match, delimiter: string, content: string) => { + if (!content.trim()) { + return match; + } + + const normalized = normalizeLatexSegment(content); + if (delimiter === "$$") { + return `$$\n${normalized}\n$$`; + } + + return `$${normalized}$`; + }); +} + +export function normalizeLatex(value: string) { + return normalizeLatexSegment(value); +} + +export function normalizeTextMathToLatex(value: string) { + return normalizeDelimitedMath(value); +} diff --git a/lib/notes/markdown.test.ts b/lib/notes/markdown.test.ts index 838786f..18e5e7f 100644 --- a/lib/notes/markdown.test.ts +++ b/lib/notes/markdown.test.ts @@ -170,4 +170,32 @@ describe("serializeNoteDocumentToMarkdown", () => { "Second block moved first\n\nFirst block moved second", ); }); + + it("serializes inline math blocks with adjacent text as one inline sentence", () => { + const document: NoteDocument = { + time: 1, + blocks: [ + { + type: "paragraph", + data: { + text: "Use", + }, + }, + { + type: "inlineMath", + data: { + latex: "\\sqrt{x^2}", + }, + }, + { + type: "paragraph", + data: { + text: "here.", + }, + }, + ], + }; + + expect(serializeNoteDocumentToMarkdown(document)).toBe("Use $\\sqrt{x^2}$ here."); + }); }); diff --git a/lib/notes/markdown.ts b/lib/notes/markdown.ts index 08bfa80..34e05dc 100644 --- a/lib/notes/markdown.ts +++ b/lib/notes/markdown.ts @@ -134,16 +134,45 @@ function serializeBlock(block: NoteBlock) { } case "math": return `$$\n${block.data.latex}\n$$`; + case "inlineMath": + return `$${block.data.latex}$`; default: return ""; } } +function getBlockSeparator(previous: NoteBlock | undefined, current: NoteBlock) { + if ( + previous && + (previous.type === "inlineMath" || current.type === "inlineMath") && + (previous.type === "paragraph" || previous.type === "inlineMath") && + (current.type === "paragraph" || current.type === "inlineMath") + ) { + return " "; + } + + return "\n\n"; +} + export function serializeNoteDocumentToMarkdown(document: NoteDocument) { - return document.blocks - .map((block) => serializeBlock(block)) - .filter((block) => block.length > 0) - .join("\n\n"); + const sections: string[] = []; + let previousSerializedBlock: NoteBlock | undefined; + + for (const block of document.blocks) { + const serialized = serializeBlock(block); + if (serialized.length === 0) { + continue; + } + + if (sections.length > 0) { + sections.push(getBlockSeparator(previousSerializedBlock, block)); + } + + sections.push(serialized); + previousSerializedBlock = block; + } + + return sections.join(""); } export function createNoteContent(document: NoteDocument): NoteContent { diff --git a/lib/notes/math-regions.test.ts b/lib/notes/math-regions.test.ts new file mode 100644 index 0000000..611bf49 --- /dev/null +++ b/lib/notes/math-regions.test.ts @@ -0,0 +1,139 @@ +import { describe, expect, it } from "vitest"; +import { normalizeNoteLatexRegions } from "@/lib/notes/math-regions"; +import type { NoteDocument } from "@/lib/notes/types"; + +describe("normalizeNoteLatexRegions", () => { + it("converts single-dollar math to inline math blocks", () => { + const document: NoteDocument = { + time: 1, + blocks: [ + { + type: "paragraph", + data: { + text: "Use $x^2 + y^2 = z^2$ for the distance relation.", + }, + }, + ], + }; + + expect(normalizeNoteLatexRegions(document).blocks).toEqual([ + { + type: "paragraph", + data: { + text: "Use", + }, + }, + { + type: "inlineMath", + data: { + latex: "x^2 + y^2 = z^2", + }, + }, + { + type: "paragraph", + data: { + text: "for the distance relation.", + }, + }, + ]); + }); + + it("converts imported inline math spans to inline math blocks", () => { + const document: NoteDocument = { + time: 1, + blocks: [ + { + type: "paragraph", + data: { + text: 'Area $\\pi r^2$', + }, + }, + ], + }; + + expect(normalizeNoteLatexRegions(document).blocks).toEqual([ + { + type: "paragraph", + data: { + text: "Area", + }, + }, + { + type: "inlineMath", + data: { + latex: "\\pi r^2", + }, + }, + ]); + }); + + it("splits display math into math blocks", () => { + const document: NoteDocument = { + time: 1, + blocks: [ + { + type: "paragraph", + data: { + text: "Before $$x^2 + y^2 = z^2$$ after", + }, + }, + ], + }; + + expect(normalizeNoteLatexRegions(document).blocks).toEqual([ + { + type: "paragraph", + data: { + text: "Before", + }, + }, + { + type: "math", + data: { + latex: "x^2 + y^2 = z^2", + }, + }, + { + type: "paragraph", + data: { + text: "after", + }, + }, + ]); + }); + + it("converts standalone raw LaTeX lines", () => { + const document: NoteDocument = { + time: 1, + blocks: [ + { + type: "paragraph", + data: { + text: "Before
x^2 + y_2 = 4
After", + }, + }, + ], + }; + + expect(normalizeNoteLatexRegions(document).blocks).toEqual([ + { + type: "paragraph", + data: { + text: "Before", + }, + }, + { + type: "math", + data: { + latex: "x^2 + y_2 = 4", + }, + }, + { + type: "paragraph", + data: { + text: "After", + }, + }, + ]); + }); +}); diff --git a/lib/notes/math-regions.ts b/lib/notes/math-regions.ts new file mode 100644 index 0000000..52516eb --- /dev/null +++ b/lib/notes/math-regions.ts @@ -0,0 +1,247 @@ +import { normalizeLatex } from "@/lib/math/latex"; +import type { NoteBlock, NoteDocument } from "@/lib/notes/types"; + +type RegionSegment = + | { + type: "text"; + value: string; + } + | { + type: "math"; + latex: string; + } + | { + type: "inlineMath"; + latex: string; + }; +type RichTextNoteBlock = Extract< + NoteBlock, + { type: "paragraph" | "header" | "quote" } +>; + +const INLINE_MATH_SPAN_RE = + /]*class="[^"]*\bnote-inline-math\b[^"]*"[^>]*data-latex="([^"]*)"[^>]*>[\s\S]*?<\/span>/gi; +const LATEX_REGION_RE = + /\$\$([\s\S]+?)\$\$|\\\[([\s\S]+?)\\\]|(?≤≥≠≈±×÷∞√∑∫→⇒∈∉∂∇∆αβγδθλμσπΩ]/; + +function decodeHtml(value: string) { + return value + .replace(/ /g, " ") + .replace(/&/g, "&") + .replace(/</g, "<") + .replace(/>/g, ">") + .replace(/"/g, '"') + .replace(/'/g, "'"); +} + +function hasInlineMathSpan(value: string) { + INLINE_MATH_SPAN_RE.lastIndex = 0; + return INLINE_MATH_SPAN_RE.test(value); +} + +function richTextToPlainText(value: string) { + return decodeHtml( + value + .replace(INLINE_MATH_SPAN_RE, (_match, latex: string) => `$${latex}$`) + .replace(//gi, "\n") + .replace(/<\/(p|div|li|blockquote|h[1-6])>/gi, "\n") + .replace(/<[^>]+>/g, ""), + ).replace(/\n{3,}/g, "\n\n"); +} + +function isStandaloneLatexLine(value: string) { + const trimmed = value.trim(); + if (!trimmed || trimmed.length > 240 || !LATEX_SIGNAL_RE.test(trimmed)) { + return false; + } + + if (/^\\begin\{[^}]+\}[\s\S]*\\end\{[^}]+\}$/.test(trimmed)) { + return true; + } + + const wordCount = (trimmed.match(/[A-Za-z]{3,}/g) ?? []).length; + const operatorCount = (trimmed.match(/[=+\-*/^_<>≤≥≠≈]/g) ?? []).length; + + return operatorCount > 0 && wordCount <= 3; +} + +function pushTextSegment(segments: RegionSegment[], value: string) { + const normalized = value.replace(/[ \t]+\n/g, "\n").replace(/\n[ \t]+/g, "\n"); + const trimmed = normalized.trim(); + if (!trimmed) { + return; + } + + const lines = trimmed.split(/\n+/); + let pendingText: string[] = []; + + const flushText = () => { + const text = pendingText.join("\n").trim(); + if (text) { + segments.push({ type: "text", value: text }); + } + pendingText = []; + }; + + for (const line of lines) { + if (isStandaloneLatexLine(line)) { + flushText(); + segments.push({ type: "math", latex: normalizeLatex(line) }); + } else { + pendingText.push(line); + } + } + + flushText(); +} + +function splitLatexRegions(value: string): RegionSegment[] { + const plainText = richTextToPlainText(value); + const segments: RegionSegment[] = []; + let lastIndex = 0; + let pendingText = ""; + + for (const match of plainText.matchAll(LATEX_REGION_RE)) { + pendingText += plainText.slice(lastIndex, match.index); + const isInline = typeof match[3] === "string" || typeof match[4] === "string"; + const latex = normalizeLatex(match[1] ?? match[2] ?? match[3] ?? match[4] ?? ""); + if (latex) { + if (isInline) { + pushTextSegment(segments, pendingText); + pendingText = ""; + segments.push({ type: "inlineMath", latex }); + } else { + pushTextSegment(segments, pendingText); + pendingText = ""; + segments.push({ type: "math", latex }); + } + } + lastIndex = (match.index ?? 0) + match[0].length; + } + + pendingText += plainText.slice(lastIndex); + pushTextSegment(segments, pendingText); + + return segments; +} + +function paragraphBlock(text: string): NoteBlock { + return { + type: "paragraph", + data: { + text, + }, + }; +} + +function mathBlock(latex: string): NoteBlock { + return { + type: "math", + data: { + latex, + }, + }; +} + +function inlineMathBlock(latex: string): NoteBlock { + return { + type: "inlineMath", + data: { + latex, + }, + }; +} + +function richTextBlockLike(block: RichTextNoteBlock, text: string): NoteBlock { + if (block.type === "header") { + return { + ...block, + data: { + ...block.data, + text, + }, + }; + } + + if (block.type === "quote") { + return { + ...block, + data: { + ...block.data, + text, + }, + }; + } + + return { + ...block, + data: { + text, + }, + }; +} + +function convertRichTextBlock(block: NoteBlock): NoteBlock[] { + if ( + block.type !== "paragraph" && + block.type !== "header" && + block.type !== "quote" + ) { + return [block]; + } + + const sourceText = block.type === "quote" ? block.data.text : block.data.text; + const signalText = sourceText.replace(/<[^>]+>/g, ""); + if ( + !sourceText || + (!sourceText.includes("$") && + !sourceText.includes("\\") && + !hasInlineMathSpan(sourceText) && + !LATEX_SIGNAL_RE.test(signalText)) + ) { + return [block]; + } + + const segments = splitLatexRegions(sourceText); + if (segments.length === 0) { + return [block]; + } + + if (segments.every((segment) => segment.type === "text")) { + const text = segments.map((segment) => segment.value).join("\n"); + return text === sourceText ? [block] : [richTextBlockLike(block, text)]; + } + + return segments.map((segment) => { + if (segment.type === "math") { + return mathBlock(segment.latex); + } + + if (segment.type === "inlineMath") { + return inlineMathBlock(segment.latex); + } + + return paragraphBlock(segment.value); + }); +} + +export function normalizeNoteLatexRegions(document: NoteDocument): NoteDocument { + let changed = false; + const blocks = document.blocks.flatMap((block) => { + const converted = convertRichTextBlock(block); + if (converted.length !== 1 || converted[0] !== block) { + changed = true; + } + return converted; + }) as NoteBlock[]; + + if (!changed) { + return document; + } + + return { + ...document, + blocks, + }; +} diff --git a/lib/notes/parse-markdown.test.ts b/lib/notes/parse-markdown.test.ts new file mode 100644 index 0000000..99c3f71 --- /dev/null +++ b/lib/notes/parse-markdown.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from "vitest"; +import { parseMarkdownToNoteDocument } from "@/lib/notes/parse-markdown"; + +describe("parseMarkdownToNoteDocument", () => { + it("normalizes inline and block math to LaTeX", () => { + const document = parseMarkdownToNoteDocument( + ["Use $√(x²)$ here.", "", "$$", "πr² ≥ 0", "$$"].join("\n"), + ); + + expect(document.blocks).toMatchObject([ + { + type: "paragraph", + data: { + text: "Use", + }, + }, + { + type: "inlineMath", + data: { + latex: "\\sqrt{x^2}", + }, + }, + { + type: "paragraph", + data: { + text: "here.", + }, + }, + { + type: "math", + data: { + latex: "\\pi r^2 \\ge 0", + }, + }, + ]); + }); +}); diff --git a/lib/notes/parse-markdown.ts b/lib/notes/parse-markdown.ts index a47d4d4..5524155 100644 --- a/lib/notes/parse-markdown.ts +++ b/lib/notes/parse-markdown.ts @@ -9,57 +9,13 @@ * - Block math ($$…$$) * - Blockquotes (>) * - Ordered / unordered / checklist lists - * - Inline math ($…$) inside paragraph text + * - Inline math ($…$) as inline math blocks * - Paragraphs (everything else) */ import type { NoteBlock, NoteDocument, NoteListBlockData, NoteListItem } from "@/lib/notes/types"; - -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -function escapeHtmlAttr(value: string) { - return value - .replace(/&/g, "&") - .replace(/"/g, """) - .replace(//g, ">"); -} - -/** - * Detect and replace inline math ($…$) within a single line of text. - * - * Heuristic: we match $content$ where content is non-empty and contains at - * least one LaTeX-typical character (backslash, caret, underscore, or braces) - * OR is a short single-token that doesn't look like a shell / currency value. - * This avoids false-positives on "$HOME", "$5.99", etc. - * - * The result is a element so - * the serializer and renderer can round-trip it cleanly. - */ -function processInlineMath(line: string): string { - // Avoid matching $$ (block math uses doubled dollar signs) - // Pattern: $$ — negative lookaround for $ - const re = /(? { - const trimmed = content.trim(); - if (!trimmed) return _match; - - // Accept as inline math if content contains any LaTeX-specific char, or - // is a multi-character expression that can't be a bare shell identifier. - const hasLatexChar = /[\\^_{}]/.test(trimmed); - const isBareShellId = /^[A-Za-z_][A-Za-z0-9_]*$/.test(trimmed); - const isCurrency = /^\d/.test(trimmed); - - if (!hasLatexChar && (isBareShellId || isCurrency)) { - return _match; // leave untouched - } - - return `$${trimmed}$`; - }); -} +import { normalizeLatex } from "@/lib/math/latex"; +import { normalizeNoteLatexRegions } from "@/lib/notes/math-regions"; function isListLine(line: string) { return /^[-*+]\s/.test(line) || /^\d+\.\s/.test(line); @@ -119,7 +75,7 @@ export function parseMarkdownToNoteDocument(markdown: string): NoteDocument { i++; } i++; // skip closing $$ - blocks.push({ type: "math", data: { latex: mathLines.join("\n") } }); + blocks.push({ type: "math", data: { latex: normalizeLatex(mathLines.join("\n")) } }); continue; } @@ -145,7 +101,7 @@ export function parseMarkdownToNoteDocument(markdown: string): NoteDocument { blocks.push({ type: "quote", data: { - text: quoteLines.map(processInlineMath).join("
"), + text: quoteLines.join("
"), caption: "", alignment: "left", }, @@ -170,10 +126,9 @@ export function parseMarkdownToNoteDocument(markdown: string): NoteDocument { const olMatch = itemLine.match(/^\d+\.\s+(.*)/); const ulMatch = itemLine.match(/^[-*+]\s+(.*)/); const rawContent = (clMatch?.[2] ?? olMatch?.[1] ?? ulMatch?.[1] ?? "").trim(); - const content = processInlineMath(rawContent); const checked = clMatch ? clMatch[1] !== " " : false; items.push({ - content, + content: rawContent, meta: style === "checklist" ? { checked } : {}, items: [], }); @@ -199,10 +154,10 @@ export function parseMarkdownToNoteDocument(markdown: string): NoteDocument { i++; } if (paragraphLines.length > 0) { - const text = paragraphLines.map(processInlineMath).join("
"); + const text = paragraphLines.join("
"); blocks.push({ type: "paragraph", data: { text } }); } } - return { time: Date.now(), blocks }; + return normalizeNoteLatexRegions({ time: Date.now(), blocks }); } diff --git a/lib/notes/persistence.ts b/lib/notes/persistence.ts index 8f44969..7dd2207 100644 --- a/lib/notes/persistence.ts +++ b/lib/notes/persistence.ts @@ -1,4 +1,5 @@ import { serializeNoteDocumentToMarkdown } from "@/lib/notes/markdown"; +import { normalizeNoteLatexRegions } from "@/lib/notes/math-regions"; import { emptyNoteDocument, NoteDocumentSchema, type NoteDocument } from "@/lib/notes/types"; export type NormalizedNoteWriteContent = { @@ -7,7 +8,9 @@ export type NormalizedNoteWriteContent = { }; export function normalizeNoteWriteContent(value: unknown = emptyNoteDocument): NormalizedNoteWriteContent { - const document = NoteDocumentSchema.parse(value ?? emptyNoteDocument); + const document = normalizeNoteLatexRegions( + NoteDocumentSchema.parse(value ?? emptyNoteDocument), + ); return { document, diff --git a/lib/notes/records.ts b/lib/notes/records.ts index a178477..b8dace3 100644 --- a/lib/notes/records.ts +++ b/lib/notes/records.ts @@ -1,4 +1,5 @@ import { createNoteContent } from "@/lib/notes/markdown"; +import { normalizeNoteLatexRegions } from "@/lib/notes/math-regions"; import { emptyNoteDocument, NoteDocumentSchema, type NoteContent, type NoteDocument } from "@/lib/notes/types"; export type NoteSourceType = "manual" | "upload"; @@ -66,7 +67,7 @@ export function normalizeNoteDocument(value: unknown): NoteDocument { return createEmptyDocument(); } - return parsed.data; + return normalizeNoteLatexRegions(parsed.data); } function normalizeEmbedding(value: unknown) { diff --git a/lib/notes/types.ts b/lib/notes/types.ts index 3471e39..0927a59 100644 --- a/lib/notes/types.ts +++ b/lib/notes/types.ts @@ -9,7 +9,8 @@ export type NoteBlockType = | "quote" | "code" | "image" - | "math"; + | "math" + | "inlineMath"; export type NoteParagraphBlockData = { text: string; @@ -68,6 +69,8 @@ export type NoteMathBlockData = { latex: string; }; +export type NoteInlineMathBlockData = NoteMathBlockData; + export type NoteBlock = | OutputBlockData<"paragraph", NoteParagraphBlockData> | OutputBlockData<"header", NoteHeaderBlockData> @@ -75,7 +78,8 @@ export type NoteBlock = | OutputBlockData<"quote", NoteQuoteBlockData> | OutputBlockData<"code", NoteCodeBlockData> | OutputBlockData<"image", NoteImageBlockData> - | OutputBlockData<"math", NoteMathBlockData>; + | OutputBlockData<"math", NoteMathBlockData> + | OutputBlockData<"inlineMath", NoteInlineMathBlockData>; export type NoteDocument = Omit & { blocks: NoteBlock[]; @@ -185,6 +189,15 @@ const mathBlockSchema = z.object({ tunes: z.record(z.string(), z.unknown()).optional(), }); +const inlineMathBlockSchema = z.object({ + id: z.string().optional(), + type: z.literal("inlineMath"), + data: z.object({ + latex: z.string(), + }), + tunes: z.record(z.string(), z.unknown()).optional(), +}); + export const NoteBlockSchema = z.discriminatedUnion("type", [ paragraphBlockSchema, headerBlockSchema, @@ -193,6 +206,7 @@ export const NoteBlockSchema = z.discriminatedUnion("type", [ codeBlockSchema, imageBlockSchema, mathBlockSchema, + inlineMathBlockSchema, ]); export const NoteDocumentSchema: z.ZodType = z