From aeff6b856cbbb8adae049ddd88f8f1778856e546 Mon Sep 17 00:00:00 2001 From: nicktytarenko Date: Sun, 25 Oct 2026 23:09:31 +0200 Subject: [PATCH 1/4] Add LaTeX math rendering for proposals and discussions. --- app/layout.tsx | 1 + app/notebook/NotebookClientLayout.tsx | 1 - .../Comment/components/EditorToolbar.tsx | 22 +++++ components/Comment/lib/KatexMath.tsx | 52 ++++++++++++ components/Comment/lib/TipTapRenderer.tsx | 81 ++++++++++++++++++- components/Comment/lib/commentContentUtils.ts | 2 + .../Comment/lib/hooks/useCommentEditor.tsx | 11 +++ .../Editor/extensions/SlashCommand/groups.ts | 7 +- components/Editor/extensions/extension-kit.ts | 4 +- 9 files changed, 173 insertions(+), 8 deletions(-) create mode 100644 components/Comment/lib/KatexMath.tsx diff --git a/app/layout.tsx b/app/layout.tsx index 434c19e47..ee6603e6e 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,6 +1,7 @@ import localFont from 'next/font/local'; import './globals.css'; +import 'katex/dist/katex.min.css'; import { Metadata } from 'next'; import { getServerSession } from 'next-auth'; import { authOptions } from './api/auth/[...nextauth]/auth.config'; diff --git a/app/notebook/NotebookClientLayout.tsx b/app/notebook/NotebookClientLayout.tsx index b2aebcda4..5460a1470 100644 --- a/app/notebook/NotebookClientLayout.tsx +++ b/app/notebook/NotebookClientLayout.tsx @@ -3,7 +3,6 @@ import { ReactNode, useState } from 'react'; import './globals.css'; import 'cal-sans/index.css'; -import 'katex/dist/katex.min.css'; import '@fontsource/inter/100.css'; import '@fontsource/inter/200.css'; diff --git a/components/Comment/components/EditorToolbar.tsx b/components/Comment/components/EditorToolbar.tsx index 48b7b5a1f..51a485ab2 100644 --- a/components/Comment/components/EditorToolbar.tsx +++ b/components/Comment/components/EditorToolbar.tsx @@ -15,6 +15,7 @@ import { Link2, ImageIcon, AtSign, + Calculator, ChevronLeft, ChevronRight, } from 'lucide-react'; @@ -224,6 +225,27 @@ const InsertButtons = ({ {inDropdown && Add Image} + { + const { from, to, empty } = editor.state.selection; + const selected = empty ? '' : editor.state.doc.textBetween(from, to, ' '); + const latex = window.prompt('Enter a LaTeX equation', selected || 'E = mc^2'); + if (latex == null || !latex.trim()) return; + const chain = editor.chain().focus(); + if (!empty) { + chain.deleteSelection(); + } + chain.insertInlineMath({ latex: latex.trim() }).run(); + }} + isActive={editor.isActive('inlineMath')} + tooltip="Insert equation" + className={buttonClasses} + inDropdown={inDropdown} + > + + {inDropdown && Equation} + + {showMentions && ( { diff --git a/components/Comment/lib/KatexMath.tsx b/components/Comment/lib/KatexMath.tsx new file mode 100644 index 000000000..29b3dd09e --- /dev/null +++ b/components/Comment/lib/KatexMath.tsx @@ -0,0 +1,52 @@ +'use client'; + +import katex from 'katex'; +import { cn } from '@/utils/styles'; + +/** Same pattern TipTap's Mathematics migration uses for `$...$` text. */ +export const INLINE_MATH_REGEX = /\$(?!\d+\$)(.+?)\$(?!\d)/g; + +export const BLOCK_MATH_REGEX = /^\$\$([\s\S]+?)\$\$/; + +interface KatexMathProps { + latex: string; + display?: boolean; + className?: string; +} + +/** + * Renders a LaTeX string with KaTeX. Falls back to the raw source when + * KaTeX cannot parse it so comments stay readable instead of blank. + */ +export function KatexMath({ latex, display = false, className }: KatexMathProps) { + try { + const html = katex.renderToString(latex, { + displayMode: display, + throwOnError: false, + }); + return ( + + ); + } catch { + return ( + + {latex} + + ); + } +} diff --git a/components/Comment/lib/TipTapRenderer.tsx b/components/Comment/lib/TipTapRenderer.tsx index 06e50c7c8..73e72846c 100644 --- a/components/Comment/lib/TipTapRenderer.tsx +++ b/components/Comment/lib/TipTapRenderer.tsx @@ -1,6 +1,6 @@ 'use client'; -import React, { ReactNode, useState } from 'react'; +import React, { Fragment, ReactNode, useState } from 'react'; import { SectionHeaderProps } from './renderUtils'; import hljs from 'highlight.js'; import { useEffect } from 'react'; @@ -8,6 +8,7 @@ import { buildWorkUrl, classifyUrl } from '@/utils/url'; import { navigateToAuthorProfile } from '@/utils/navigation'; import { InlineRichLink } from '@/components/Embed'; import { normalizeRichLinks } from './embedDoc'; +import { BLOCK_MATH_REGEX, INLINE_MATH_REGEX, KatexMath } from './KatexMath'; interface TipTapRendererProps { content: any; @@ -68,6 +69,48 @@ export const renderTextWithMarks = (text: string, marks: any[]): ReactNode => { return result; }; +/** + * Renders text, turning legacy `$...$` segments into KaTeX (comments saved + * before the Mathematics extension). Code marks are left as literal source. + */ +export const renderTextWithInlineMath = (text: string, marks: any[] = []): ReactNode => { + if (!text) return null; + + if (marks.some((mark) => mark.type === 'code')) { + return renderTextWithMarks(text, marks); + } + + const nodes: ReactNode[] = []; + let lastIndex = 0; + let key = 0; + const regex = new RegExp(INLINE_MATH_REGEX.source, INLINE_MATH_REGEX.flags); + let match: RegExpExecArray | null; + + while ((match = regex.exec(text)) !== null) { + if (match.index > lastIndex) { + nodes.push( + + {renderTextWithMarks(text.slice(lastIndex, match.index), marks)} + + ); + } + nodes.push(); + lastIndex = match.index + match[0].length; + } + + if (nodes.length === 0) { + return renderTextWithMarks(text, marks); + } + + if (lastIndex < text.length) { + nodes.push( + {renderTextWithMarks(text.slice(lastIndex), marks)} + ); + } + + return <>{nodes}; +}; + function demoteUrlToLinkedText(url: string) { return { type: 'text', @@ -119,6 +162,10 @@ export const extractPlainText = (node: any): string => { return name; } + if (node.type === 'inlineMath' || node.type === 'blockMath') { + return node.attrs?.latex || ''; + } + // If it has content, recursively extract text from all children if (node.content && Array.isArray(node.content)) { return node.content.map(extractPlainText).join(''); @@ -413,17 +460,45 @@ const RenderNode: React.FC = ({ if (debug) console.log('TRUNCATING TEXT to', truncatedText + '...'); return ( <> - {renderTextWithMarks(truncatedText, node.marks || [])} + {renderTextWithInlineMath(truncatedText, node.marks || [])} ... ); } - return renderTextWithMarks(text, node.marks || []); + return renderTextWithInlineMath(text, node.marks || []); + } + + // TipTap Mathematics nodes (saved after the extension was enabled) + if (node.type === 'inlineMath') { + const latex = node.attrs?.latex || ''; + if (!latex) return null; + return ; + } + + if (node.type === 'blockMath') { + const latex = node.attrs?.latex || ''; + if (!latex) return null; + return ; } // Handle paragraph nodes if (node.type === 'paragraph') { + // A paragraph whose only content is `$$...$$` is treated as display math + // for comments authored before blockMath nodes existed. + if ( + Array.isArray(node.content) && + node.content.length === 1 && + node.content[0]?.type === 'text' && + typeof node.content[0].text === 'string' + ) { + const trimmed = node.content[0].text.trim(); + const blockMatch = trimmed.match(BLOCK_MATH_REGEX); + if (blockMatch) { + return ; + } + } + const { children } = renderChildrenWithTruncation(node.content, { debug, renderSectionHeader, diff --git a/components/Comment/lib/commentContentUtils.ts b/components/Comment/lib/commentContentUtils.ts index 04269b1d8..85a2a8ea4 100644 --- a/components/Comment/lib/commentContentUtils.ts +++ b/components/Comment/lib/commentContentUtils.ts @@ -173,6 +173,8 @@ export const extractTextFromTipTap = (tipTapContent: any): string => { const processNode = (node: any): void => { if (node.text) { text += node.text; + } else if (node.type === 'inlineMath' || node.type === 'blockMath') { + text += node.attrs?.latex || ''; } else if (node.content && Array.isArray(node.content)) { node.content.forEach(processNode); } diff --git a/components/Comment/lib/hooks/useCommentEditor.tsx b/components/Comment/lib/hooks/useCommentEditor.tsx index 6cd6c3d7a..713601605 100644 --- a/components/Comment/lib/hooks/useCommentEditor.tsx +++ b/components/Comment/lib/hooks/useCommentEditor.tsx @@ -1,6 +1,7 @@ import { useEditor, Content, JSONContent } from '@tiptap/react'; import { StarterKit } from '@tiptap/starter-kit'; import { Underline } from '@tiptap/extension-underline'; +import { Mathematics, migrateMathStrings } from '@tiptap/extension-mathematics'; import { CommentLink } from '../extensions/CommentLink'; import { Image } from '@tiptap/extension-image'; import { CodeBlockLowlight } from '@tiptap/extension-code-block-lowlight'; @@ -147,6 +148,9 @@ export const useCommentEditor = ({ }, }), MentionExtension, + Mathematics.configure({ + katexOptions: { throwOnError: false }, + }), Placeholder.configure({ placeholder, emptyEditorClass: 'is-editor-empty', @@ -167,6 +171,11 @@ export const useCommentEditor = ({ class: 'prose prose-sm max-w-none focus:outline-none min-h-[100px] px-4 py-2', }, }, + onCreate: ({ editor }) => { + // Convert legacy `$...$` text (and drafts saved before Mathematics) + // into inlineMath nodes so KaTeX can render them. + migrateMathStrings(editor); + }, onUpdate: ({ editor }) => { if (!isReadOnly) { const json = editor.getJSON(); @@ -214,6 +223,7 @@ export const useCommentEditor = ({ // upgraded into richLink nodes when the draft is restored. if (debug) console.log('Loading content from localStorage:', loadedContent); editor.commands.setContent(normalizeRichLinks(loadedContent), { emitUpdate: false }); + migrateMathStrings(editor); } else if (initialContent) { // If format is HTML, set the content directly. TipTap's HTML parser // will route any `a[data-type="rich-link"]` tags through the @@ -234,6 +244,7 @@ export const useCommentEditor = ({ editor.commands.setContent(normalized, { emitUpdate: false }); } + migrateMathStrings(editor); } } }, [editor, initialContent, loadedContent, isReadOnly, debug, format]); diff --git a/components/Editor/extensions/SlashCommand/groups.ts b/components/Editor/extensions/SlashCommand/groups.ts index d47b02c32..c0c40d7e9 100644 --- a/components/Editor/extensions/SlashCommand/groups.ts +++ b/components/Editor/extensions/SlashCommand/groups.ts @@ -211,9 +211,10 @@ export const GROUPS: Group[] = [ editor .chain() .focus() - .insertContent( - '$\\text{Total Supply} = \\sum_{i=0}^{32} \\left( 210{,}000 \\times \\frac{50}{2^i} \\right)$' - ) + .insertInlineMath({ + latex: + '\\text{Total Supply} = \\sum_{i=0}^{32} \\left( 210{,}000 \\times \\frac{50}{2^i} \\right)', + }) .run(); }, }, diff --git a/components/Editor/extensions/extension-kit.ts b/components/Editor/extensions/extension-kit.ts index c5daeb0c8..604e4e179 100644 --- a/components/Editor/extensions/extension-kit.ts +++ b/components/Editor/extensions/extension-kit.ts @@ -150,7 +150,9 @@ export const ExtensionKit = ({ customDocument, placeholderConfig }: ExtensionKit }), Subscript, Superscript, - Mathematics, + Mathematics.configure({ + katexOptions: { throwOnError: false }, + }), Table, TableCell, TableHeader, From 398b6d75d15d8f892f94948f2772896e0137de2f Mon Sep 17 00:00:00 2001 From: nicktytarenko Date: Tue, 27 Oct 2026 23:06:17 +0200 Subject: [PATCH 2/4] Update INLINE_MATH_REGEX to exclude display math and improve regex accuracy --- components/Comment/lib/KatexMath.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/components/Comment/lib/KatexMath.tsx b/components/Comment/lib/KatexMath.tsx index 29b3dd09e..3ad504d6c 100644 --- a/components/Comment/lib/KatexMath.tsx +++ b/components/Comment/lib/KatexMath.tsx @@ -3,8 +3,8 @@ import katex from 'katex'; import { cn } from '@/utils/styles'; -/** Same pattern TipTap's Mathematics migration uses for `$...$` text. */ -export const INLINE_MATH_REGEX = /\$(?!\d+\$)(.+?)\$(?!\d)/g; +/** `$...$` inline only — excludes `$$` so display math is not partially eaten. */ +export const INLINE_MATH_REGEX = /(? Date: Tue, 27 Oct 2026 23:07:48 +0200 Subject: [PATCH 3/4] Fix BLOCK_MATH_REGEX to correctly match display math syntax --- components/Comment/lib/KatexMath.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/components/Comment/lib/KatexMath.tsx b/components/Comment/lib/KatexMath.tsx index 3ad504d6c..1f92b55bb 100644 --- a/components/Comment/lib/KatexMath.tsx +++ b/components/Comment/lib/KatexMath.tsx @@ -6,7 +6,7 @@ import { cn } from '@/utils/styles'; /** `$...$` inline only — excludes `$$` so display math is not partially eaten. */ export const INLINE_MATH_REGEX = /(? Date: Tue, 27 Oct 2026 23:09:37 +0200 Subject: [PATCH 4/4] Enhance TipTapRenderer to exclude code marks from text nodes before processing for BLOCK_MATH_REGEX --- components/Comment/lib/TipTapRenderer.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/components/Comment/lib/TipTapRenderer.tsx b/components/Comment/lib/TipTapRenderer.tsx index 73e72846c..add330871 100644 --- a/components/Comment/lib/TipTapRenderer.tsx +++ b/components/Comment/lib/TipTapRenderer.tsx @@ -490,7 +490,8 @@ const RenderNode: React.FC = ({ Array.isArray(node.content) && node.content.length === 1 && node.content[0]?.type === 'text' && - typeof node.content[0].text === 'string' + typeof node.content[0].text === 'string' && + !node.content[0].marks?.some((mark: { type?: string }) => mark.type === 'code') ) { const trimmed = node.content[0].text.trim(); const blockMatch = trimmed.match(BLOCK_MATH_REGEX);