diff --git a/src/components/editor/BlockNoteEditor.tsx b/src/components/editor/BlockNoteEditor.tsx index 591cbb09..0931d0c7 100644 --- a/src/components/editor/BlockNoteEditor.tsx +++ b/src/components/editor/BlockNoteEditor.tsx @@ -11,6 +11,7 @@ import { useEffect, useRef, useCallback } from "react"; import { toast } from "sonner"; import { schema } from "./schema"; import { uploadFile } from "@/lib/editor/upload-file"; +import { convertMathInBlocks, normalizeMathSyntax } from "@/lib/editor/math-utils"; import { useWorkspaceStore } from "@/lib/stores/workspace-store"; import { useUIStore } from "@/lib/stores/ui-store"; import { extractTextFromSelection } from "@/lib/utils/extract-blocknote-text"; @@ -72,226 +73,231 @@ export default function BlockNoteEditor({ initialContent, onChange, readOnly, ca }; }, [cardName, readOnly]); - // Custom paste handler to detect and handle math content and images from clipboard - const pasteHandler = ({ event, editor, defaultPasteHandler }: any) => { - const clipboardData = event.clipboardData; - if (!clipboardData) { - return defaultPasteHandler(); + // Paste handler is attached via useEffect after editor is fully initialized + // to avoid race condition where _exportManager isn't ready yet + + // Wrapper for uploadFile that matches BlockNote's expected signature + // BlockNote expects: (file: File, blockId?: string) => Promise> + const blockNoteUploadFile = useCallback(async (file: File, blockId?: string) => { + // Show loading toast + const toastId = toast.loading('Uploading image...', { + style: { color: 'white' }, + }); + + try { + // Upload the image (don't show toast in uploadFile since we're handling it here) + // Pass workspaceId for Supermemory background upload and cardName for attachment naming + const url = await uploadFile(file, false, currentWorkspaceId, cardName); + + // Show success toast + toast.success('Image uploaded successfully!', { + id: toastId, + style: { color: 'white' }, + }); + + return url; + } catch (error) { + // Show error toast + const errorMessage = error instanceof Error ? error.message : 'Failed to upload image'; + toast.error(errorMessage, { + id: toastId, + style: { color: 'white' }, + }); + throw error; } + }, [currentWorkspaceId, cardName]); + + // Creates a new editor instance with custom schema + const editorInitStart = performance.now(); + const editor = useCreateBlockNote({ + schema, + initialContent: initialContent && initialContent.length > 0 ? initialContent : undefined, + uploadFile: blockNoteUploadFile, + dictionary: en, + autofocus: autofocus ? (typeof autofocus === "boolean" ? "start" : autofocus) : false, + }); - // Get clipboard text content - const textContent = clipboardData.getData('text/plain'); + // Custom paste handler attached after editor initialization to ensure _exportManager is ready + // This fixes the race condition where tryParseMarkdownToBlocks would fail on early pastes + useEffect(() => { + if (!editor) return; + + const domElement = editor.domElement; + if (!domElement) return; + + const handlePaste = async (event: ClipboardEvent) => { + const clipboardData = event.clipboardData; + if (!clipboardData) return; + + // Get clipboard text content + const textContent = clipboardData.getData('text/plain'); + const markdownContent = clipboardData.getData('text/markdown'); + + // Detect if content looks like markdown + const looksLikeMarkdown = (text: string) => { + return ( + /(^|\n)#{1,6}\s/.test(text) || // Headers + /(^|\n)\|.+\|/.test(text) || // Tables + /```/.test(text) || // Code blocks + /\[[^\]]+\]\([^)]+\)/.test(text) || // Links + /(^|\n)(-|\*)\s/.test(text) // Lists + ); + }; - // Helper function to extract LaTeX from math delimiters - // Only matches if the entire content is just math (no surrounding text) - const extractMathContent = (text: string): { type: 'block' | 'inline'; latex: string } | null => { - const trimmed = text.trim(); + const markdownText = markdownContent || (textContent && looksLikeMarkdown(textContent) ? textContent : ""); - // Check for block math: $$...$$ or \[...\] - const blockMathMatch = trimmed.match(/^\$\$([\s\S]*?)\$\$$/) || trimmed.match(/^\\\[([\s\S]*?)\\\]$/); - if (blockMathMatch) { - return { type: 'block', latex: blockMathMatch[1].trim() }; - } + // Handle markdown paste - render with proper formatting + if (markdownText) { + const parseMarkdown = editor?.tryParseMarkdownToBlocks; + if (typeof parseMarkdown === "function") { + event.preventDefault(); + event.stopPropagation(); - // Check for inline math: $...$ or \(...\) - // Only match if the entire content is just the math (no surrounding text) - const inlineMathMatch = trimmed.match(/^\$([^$\n]+?)\$$/) || trimmed.match(/^\\\(([\s\S]*?)\\\)$/); - if (inlineMathMatch) { - return { type: 'inline', latex: inlineMathMatch[1].trim() }; + try { + const currentBlock = editor.getTextCursorPosition().block; + const normalizedMarkdown = normalizeMathSyntax(markdownText); + const blocks = await parseMarkdown(normalizedMarkdown); + const processedBlocks = convertMathInBlocks(blocks); + + editor.insertBlocks(processedBlocks, currentBlock, "after"); + } catch (error) { + console.error("[BlockNoteEditor] Markdown paste failed:", error); + // Fall back to inserting as plain text + editor.insertInlineContent([{ type: "text", text: markdownText, styles: {} }]); + } + return; + } } - return null; - }; + // Helper function to extract LaTeX from math delimiters + const extractMathContent = (text: string): { type: 'block' | 'inline'; latex: string } | null => { + const trimmed = text.trim(); - // Check if clipboard contains only math content (no surrounding text) - // This handles cases where math is selected and pasted directly - if (textContent) { - const mathContent = extractMathContent(textContent); + // Check for block math: $$...$$ or \[...\] + const blockMathMatch = trimmed.match(/^\$\$([\s\S]*?)\$\$$/) || trimmed.match(/^\\\[([\s\S]*?)\\\]$/); + if (blockMathMatch) { + return { type: 'block', latex: blockMathMatch[1].trim() }; + } - if (mathContent) { - const currentBlock = editor.getTextCursorPosition().block; + // Check for inline math: $...$ or \(...\) + const inlineMathMatch = trimmed.match(/^\$([^$\n]+?)\$$/) || trimmed.match(/^\\\(([\s\S]*?)\\\)$/); + if (inlineMathMatch) { + return { type: 'inline', latex: inlineMathMatch[1].trim() }; + } - if (mathContent.type === 'block') { - // Insert block math after current block - editor.insertBlocks( - [ - { - type: 'math', - props: { - latex: mathContent.latex, - }, - }, - ], - currentBlock, - 'after' - ); - return true; - } else if (mathContent.type === 'inline') { - // Insert inline math at cursor position - editor.insertInlineContent([ - { - type: 'inlineMath', - props: { - latex: mathContent.latex, - }, - }, - ]); - return true; + return null; + }; + + // Check if clipboard contains only math content + if (textContent) { + const mathContent = extractMathContent(textContent); + + if (mathContent) { + event.preventDefault(); + event.stopPropagation(); + + const currentBlock = editor.getTextCursorPosition().block; + + if (mathContent.type === 'block') { + editor.insertBlocks( + [{ type: 'math', props: { latex: mathContent.latex } }], + currentBlock, + 'after' + ); + } else { + editor.insertInlineContent([ + { type: 'inlineMath', props: { latex: mathContent.latex } }, + ]); + } + return; } } - } - // Check if clipboard contains image files - const items = Array.from(clipboardData.items) as DataTransferItem[]; - const imageItem = items.find((item: DataTransferItem) => item.type.startsWith('image/')); + // Check if clipboard contains image files + const items = Array.from(clipboardData.items) as DataTransferItem[]; + const imageItem = items.find((item: DataTransferItem) => item.type.startsWith('image/')); - if (imageItem) { - // Get the image file from clipboard - const file = imageItem.getAsFile(); - if (file) { - // Show toast that image was detected - const toastId = toast.loading('Uploading image from clipboard...', { - style: { color: 'white' }, - }); + if (imageItem) { + const file = imageItem.getAsFile(); + if (file) { + event.preventDefault(); + event.stopPropagation(); - // Handle upload asynchronously - uploadFile(file, false, currentWorkspaceId, cardName) - .then((imageUrl) => { - // Get the current block (where cursor is) + const toastId = toast.loading('Uploading image from clipboard...', { + style: { color: 'white' }, + }); + + try { + const imageUrl = await uploadFile(file, false, currentWorkspaceId, cardName); const currentBlock = editor.getTextCursorPosition().block; - // Insert image block after current block editor.insertBlocks( - [ - { - type: 'image', - props: { - url: imageUrl, - }, - }, - ], + [{ type: 'image', props: { url: imageUrl } }], currentBlock, 'after' ); - // Show success toast toast.success('Image uploaded successfully!', { id: toastId, style: { color: 'white' }, }); - }) - .catch((error) => { + } catch (error) { console.error('Error uploading pasted image:', error); - // Show error toast toast.error(error instanceof Error ? error.message : 'Failed to upload image', { id: toastId, style: { color: 'white' }, }); - // If upload fails, fall back to default handler - defaultPasteHandler(); - }); - - // We handled the paste, so return true - return true; + } + return; + } } - } - // Check if clipboard contains files (not just images) - const files = Array.from(clipboardData.files) as File[]; - const imageFile = files.find((file: File) => file.type.startsWith('image/')); + // Check if clipboard contains image files (via files array) + const files = Array.from(clipboardData.files) as File[]; + const imageFile = files.find((file: File) => file.type.startsWith('image/')); - if (imageFile) { - // Show toast that image was detected - const toastId = toast.loading('Uploading image from clipboard...', { - style: { color: 'white' }, - }); + if (imageFile) { + event.preventDefault(); + event.stopPropagation(); - // Handle upload asynchronously - uploadFile(imageFile, false, currentWorkspaceId, cardName) - .then((imageUrl) => { - // Get the current block (where cursor is) + const toastId = toast.loading('Uploading image from clipboard...', { + style: { color: 'white' }, + }); + + try { + const imageUrl = await uploadFile(imageFile, false, currentWorkspaceId, cardName); const currentBlock = editor.getTextCursorPosition().block; - // Insert image block after current block editor.insertBlocks( - [ - { - type: 'image', - props: { - url: imageUrl, - }, - }, - ], + [{ type: 'image', props: { url: imageUrl } }], currentBlock, 'after' ); - // Show success toast toast.success('Image uploaded successfully!', { id: toastId, style: { color: 'white' }, }); - }) - .catch((error) => { + } catch (error) { console.error('Error uploading pasted image file:', error); - // Show error toast toast.error(error instanceof Error ? error.message : 'Failed to upload image', { id: toastId, style: { color: 'white' }, }); - // If upload fails, fall back to default handler - defaultPasteHandler(); - }); - - // We handled the paste, so return true - return true; - } - - // No image found, use default paste handler - return defaultPasteHandler(); - }; - - // Wrapper for uploadFile that matches BlockNote's expected signature - // BlockNote expects: (file: File, blockId?: string) => Promise> - const blockNoteUploadFile = useCallback(async (file: File, blockId?: string) => { - // Show loading toast - const toastId = toast.loading('Uploading image...', { - style: { color: 'white' }, - }); + } + return; + } - try { - // Upload the image (don't show toast in uploadFile since we're handling it here) - // Pass workspaceId for Supermemory background upload and cardName for attachment naming - const url = await uploadFile(file, false, currentWorkspaceId, cardName); + // If none of our handlers caught it, let BlockNote's default handler take over + }; - // Show success toast - toast.success('Image uploaded successfully!', { - id: toastId, - style: { color: 'white' }, - }); + // Use capture phase to intercept before BlockNote's default handler + domElement.addEventListener('paste', handlePaste, true); - return url; - } catch (error) { - // Show error toast - const errorMessage = error instanceof Error ? error.message : 'Failed to upload image'; - toast.error(errorMessage, { - id: toastId, - style: { color: 'white' }, - }); - throw error; - } - }, [currentWorkspaceId, cardName]); - - // Creates a new editor instance with custom schema - const editorInitStart = performance.now(); - const editor = useCreateBlockNote({ - schema, - initialContent: initialContent && initialContent.length > 0 ? initialContent : undefined, - uploadFile: blockNoteUploadFile, - pasteHandler, - dictionary: en, - autofocus: autofocus ? (typeof autofocus === "boolean" ? "start" : autofocus) : false, - }); + return () => { + domElement.removeEventListener('paste', handlePaste, true); + }; + }, [editor, currentWorkspaceId, cardName]); useEffect(() => { const initTime = performance.now() - editorInitStart; diff --git a/src/lib/editor/markdown-to-blocks.ts b/src/lib/editor/markdown-to-blocks.ts index d2601f87..bb3196d3 100644 --- a/src/lib/editor/markdown-to-blocks.ts +++ b/src/lib/editor/markdown-to-blocks.ts @@ -1,10 +1,9 @@ import { ServerBlockNoteEditor } from "@blocknote/server-util"; +import { normalizeMathSyntax, convertMathInBlocks } from "./math-utils"; // Block type from server-util (no custom schema needed) type ServerBlock = any; - - /** * Converts markdown content to BlockNote blocks, with comprehensive LaTeX math conversion. * @@ -34,293 +33,3 @@ export async function markdownToBlocks(markdown: string): Promise return processedBlocks; } -/** - * Normalizes various LaTeX math syntaxes to standard $$ format (Streamdown compatible) - * This ensures math is preserved in the BlockNote document - */ -function normalizeMathSyntax(markdown: string): string { - return markdown - // Convert \[...\] to $$...$$ (block math) - .replace(/\\\[([\\s\\S]*?)\\\]/g, (_, latex) => `$$${latex.trim()}$$`) - // Convert \(...\) to $$...$$ (inline math) - Streamdown uses $$ for both - .replace(/\\\(([\\s\\S]*?)\\\)/g, (_, latex) => `$$${latex.trim()}$$`) - // Keep existing $$...$$ as-is - ; -} - - -/** - * Regex pattern for matching Streamdown math: $$...$$ - * Matches $$ delimiters with content in between (non-greedy) - */ -const MATH_REGEX = /\$\$([^$]+?)\$\$/g; - -/** - * Regex to check if content is ONLY a single math expression (for block math detection) - * Matches: optional whitespace + $$...$$ + optional whitespace - */ -const BLOCK_MATH_ONLY_REGEX = /^\s*\$\$([^$]+?)\$\$\s*$/; - -/** - * Converts math in blocks: - * - Paragraphs with ONLY $$...$$ become block math - * - $$...$$ within text becomes inlineMath - */ -function convertMathInBlocks(blocks: ServerBlock[]): ServerBlock[] { - const result: ServerBlock[] = []; - - for (const block of blocks) { - const processed = processBlockForMath(block); - // processBlockForMath can return one block or an array of blocks - if (Array.isArray(processed)) { - result.push(...processed); - } else { - result.push(processed); - } - } - - return result; -} - -/** - * Process a single block for math conversion. - * Returns the processed block(s) - may return multiple blocks if splitting is needed. - */ -function processBlockForMath(block: ServerBlock): ServerBlock | ServerBlock[] { - // Check if this is a paragraph that should become a math block - if (block.type === "paragraph" && block.content && Array.isArray(block.content)) { - // Get the full text content of the paragraph - const fullText = block.content - .filter((item: any) => item.type === "text") - .map((item: any) => item.text || "") - .join(""); - - // Check if the paragraph contains ONLY a single math expression - const blockMathMatch = fullText.match(BLOCK_MATH_ONLY_REGEX); - if (blockMathMatch) { - // Convert to block math - const latex = blockMathMatch[1].trim(); - return { - id: block.id, - type: "math", - props: { - latex: latex, - }, - children: [], - }; - } - - // Otherwise, process inline math within the paragraph content - const processedContent = processInlineMathInContent(block.content); - const contentChanged = JSON.stringify(processedContent) !== JSON.stringify(block.content); - - if (contentChanged) { - return { - ...block, - content: processedContent, - children: block.children ? processChildBlocks(block.children) : [], - }; - } - } - - // For other block types, process their content for inline math - let processedBlock = { ...block }; - - // Process inline content (for headings, list items, etc.) - if (block.content && Array.isArray(block.content) && block.type !== "paragraph") { - const processedContent = processInlineMathInContent(block.content); - const contentChanged = JSON.stringify(processedContent) !== JSON.stringify(block.content); - if (contentChanged) { - processedBlock = { ...processedBlock, content: processedContent }; - } - } - - // Process table cells (special structure) - if (block.type === "table" && block.content) { - let rows: any[] = []; - - if (Array.isArray(block.content)) { - rows = block.content; - } else if (block.content && typeof block.content === 'object' && 'rows' in block.content) { - rows = (block.content as any).rows || []; - } - - if (rows.length > 0) { - const processedRows = rows.map((row: any) => { - if (!row || !row.cells || !Array.isArray(row.cells)) return row; - - const processedCells = row.cells.map((cell: any) => { - if (!cell || !cell.content || !Array.isArray(cell.content)) return cell; - - const processedCellContent = processInlineMathInContent(cell.content); - - const fullyProcessedContent = processedCellContent.map((item: any) => { - if (item && typeof item === 'object' && 'type' in item && 'id' in item && item.children) { - const result = processBlockForMath(item); - return Array.isArray(result) ? result[0] : result; - } - return item; - }); - - return { - ...cell, - content: fullyProcessedContent, - }; - }); - - return { - ...row, - cells: processedCells, - }; - }); - - const processedTableContent = Array.isArray(block.content) - ? processedRows - : { ...(block.content as any), rows: processedRows }; - - processedBlock = { - ...processedBlock, - content: processedTableContent, - }; - } - } - - // Recursively process children - if (block.children && Array.isArray(block.children) && block.children.length > 0) { - processedBlock = { - ...processedBlock, - children: processChildBlocks(block.children), - }; - } - - return processedBlock; -} - -/** - * Process an array of child blocks recursively. - */ -function processChildBlocks(blocks: ServerBlock[]): ServerBlock[] { - const result: ServerBlock[] = []; - for (const block of blocks) { - const processed = processBlockForMath(block); - if (Array.isArray(processed)) { - result.push(...processed); - } else { - result.push(processed); - } - } - return result; -} - -/** - * Processes Streamdown math patterns ($$...$$) in inline content array. - * Splits text items that contain math and converts them to inlineMath elements. - * - * Note: This is for inline math only - block math is handled at the block level. - * - * Example: - * Input: [{ type: "text", text: "The equation $$E=mc^2$$ is famous." }] - * Output: [ - * { type: "text", text: "The equation " }, - * { type: "inlineMath", props: { latex: "E=mc^2" } }, - * { type: "text", text: " is famous." } - * ] - */ -function processInlineMathInContent( - content: Array<{ type: string; text?: string;[key: string]: any }> -): Array { - const processed: any[] = []; - - for (const item of content) { - if (item.type === "text" && item.text) { - const text = item.text; - const parts: Array<{ type: string; text?: string; props?: { latex: string } }> = []; - let lastIndex = 0; - - // Find all math matches - let match: RegExpExecArray | null; - MATH_REGEX.lastIndex = 0; // Reset regex - - while ((match = MATH_REGEX.exec(text)) !== null) { - const matchStart = match.index; - const matchEnd = match.index + match[0].length; - const latex = match[1].trim(); - - // Add text before the math - if (matchStart > lastIndex) { - const beforeText = text.substring(lastIndex, matchStart); - if (beforeText) { - parts.push({ - type: "text", - text: beforeText, - // Preserve other properties (styles, etc.) - ...Object.fromEntries( - Object.entries(item).filter(([key]) => key !== "type" && key !== "text") - ), - }); - } - } - - // Add inline math element - parts.push({ - type: "inlineMath", - props: { latex }, - }); - - lastIndex = matchEnd; - } - - // Add remaining text after last match - if (lastIndex < text.length) { - const afterText = text.substring(lastIndex); - if (afterText) { - parts.push({ - type: "text", - text: afterText, - // Preserve other properties - ...Object.fromEntries( - Object.entries(item).filter(([key]) => key !== "type" && key !== "text") - ), - }); - } - } - - // If we found math, use processed parts; otherwise keep original item - if (parts.length > 0) { - processed.push(...parts); - } else { - processed.push(item); - } - } else { - // Non-text items are kept as-is - processed.push(item); - } - } - - // Remove periods (and leading spaces before them) that immediately follow inline math - const cleaned: any[] = []; - for (let i = 0; i < processed.length; i++) { - const current = processed[i]; - const prev = i > 0 ? processed[i - 1] : null; - - // If current is text and previous is inlineMath, remove leading periods and spaces before them - if (current.type === "text" && current.text && prev && prev.type === "inlineMath") { - // Remove leading spaces followed by periods (e.g., " ." or ".") - // But preserve other text like " and " between math expressions - const cleanedText = current.text.replace(/^\s*\.+/, ''); - - // Only add if there's remaining text after cleaning - if (cleanedText) { - cleaned.push({ - ...current, - text: cleanedText, - }); - } - // If text becomes empty after cleaning (was just ". " or "."), skip it entirely - } else { - cleaned.push(current); - } - } - - return cleaned; -} diff --git a/src/lib/editor/math-utils.ts b/src/lib/editor/math-utils.ts new file mode 100644 index 00000000..5e8c21b8 --- /dev/null +++ b/src/lib/editor/math-utils.ts @@ -0,0 +1,287 @@ +/** + * Math utility functions for BlockNote editor + * Used for both server-side markdown conversion and client-side paste handling + */ + +// Block type placeholder (works with any block structure) +type AnyBlock = any; + +/** + * Normalizes various LaTeX math syntaxes to standard $$ format (Streamdown compatible) + * This ensures math is preserved in the BlockNote document + */ +export function normalizeMathSyntax(markdown: string): string { + return markdown + // Convert \[...\] to $$...$$ (block math) + .replace(/\\\[([\\s\\S]*?)\\\]/g, (_, latex) => `$$${latex.trim()}$$`) + // Convert \(...\) to $$...$$ (inline math) - Streamdown uses $$ for both + .replace(/\\\(([\\s\\S]*?)\\\)/g, (_, latex) => `$$${latex.trim()}$$`) + // Keep existing $$...$$ as-is + ; +} + +/** + * Regex pattern for matching Streamdown math: $$...$$ + * Matches $$ delimiters with content in between (non-greedy) + */ +const MATH_REGEX = /\$\$([^$]+?)\$\$/g; + +/** + * Regex to check if content is ONLY a single math expression (for block math detection) + * Matches: optional whitespace + $$...$$ + optional whitespace + */ +const BLOCK_MATH_ONLY_REGEX = /^\s*\$\$([^$]+?)\$\$\s*$/; + +/** + * Converts math in blocks: + * - Paragraphs with ONLY $$...$$ become block math + * - $$...$$ within text becomes inlineMath + */ +export function convertMathInBlocks(blocks: AnyBlock[]): AnyBlock[] { + const result: AnyBlock[] = []; + + for (const block of blocks) { + const processed = processBlockForMath(block); + // processBlockForMath can return one block or an array of blocks + if (Array.isArray(processed)) { + result.push(...processed); + } else { + result.push(processed); + } + } + + return result; +} + +/** + * Process a single block for math conversion. + * Returns the processed block(s) - may return multiple blocks if splitting is needed. + */ +function processBlockForMath(block: AnyBlock): AnyBlock | AnyBlock[] { + // Check if this is a paragraph that should become a math block + if (block.type === "paragraph" && block.content && Array.isArray(block.content)) { + // Get the full text content of the paragraph + const fullText = block.content + .filter((item: any) => item.type === "text") + .map((item: any) => item.text || "") + .join(""); + + // Check if the paragraph contains ONLY a single math expression + const blockMathMatch = fullText.match(BLOCK_MATH_ONLY_REGEX); + if (blockMathMatch) { + // Convert to block math + const latex = blockMathMatch[1].trim(); + return { + id: block.id, + type: "math", + props: { + latex: latex, + }, + children: [], + }; + } + + // Otherwise, process inline math within the paragraph content + const processedContent = processInlineMathInContent(block.content); + const contentChanged = JSON.stringify(processedContent) !== JSON.stringify(block.content); + + if (contentChanged) { + return { + ...block, + content: processedContent, + children: block.children ? processChildBlocks(block.children) : [], + }; + } + } + + // For other block types, process their content for inline math + let processedBlock = { ...block }; + + // Process inline content (for headings, list items, etc.) + if (block.content && Array.isArray(block.content) && block.type !== "paragraph") { + const processedContent = processInlineMathInContent(block.content); + const contentChanged = JSON.stringify(processedContent) !== JSON.stringify(block.content); + if (contentChanged) { + processedBlock = { ...processedBlock, content: processedContent }; + } + } + + // Process table cells (special structure) + if (block.type === "table" && block.content) { + let rows: any[] = []; + + if (Array.isArray(block.content)) { + rows = block.content; + } else if (block.content && typeof block.content === 'object' && 'rows' in block.content) { + rows = (block.content as any).rows || []; + } + + if (rows.length > 0) { + const processedRows = rows.map((row: any) => { + if (!row || !row.cells || !Array.isArray(row.cells)) return row; + + const processedCells = row.cells.map((cell: any) => { + if (!cell || !cell.content || !Array.isArray(cell.content)) return cell; + + const processedCellContent = processInlineMathInContent(cell.content); + + const fullyProcessedContent = processedCellContent.map((item: any) => { + if (item && typeof item === 'object' && 'type' in item && 'id' in item && item.children) { + const result = processBlockForMath(item); + return Array.isArray(result) ? result[0] : result; + } + return item; + }); + + return { + ...cell, + content: fullyProcessedContent, + }; + }); + + return { + ...row, + cells: processedCells, + }; + }); + + const processedTableContent = Array.isArray(block.content) + ? processedRows + : { ...(block.content as any), rows: processedRows }; + + processedBlock = { + ...processedBlock, + content: processedTableContent, + }; + } + } + + // Recursively process children + if (block.children && Array.isArray(block.children) && block.children.length > 0) { + processedBlock = { + ...processedBlock, + children: processChildBlocks(block.children), + }; + } + + return processedBlock; +} + +/** + * Process an array of child blocks recursively. + */ +function processChildBlocks(blocks: AnyBlock[]): AnyBlock[] { + const result: AnyBlock[] = []; + for (const block of blocks) { + const processed = processBlockForMath(block); + if (Array.isArray(processed)) { + result.push(...processed); + } else { + result.push(processed); + } + } + return result; +} + +/** + * Processes Streamdown math patterns ($$...$$) in inline content array. + * Splits text items that contain math and converts them to inlineMath elements. + */ +function processInlineMathInContent( + content: Array<{ type: string; text?: string;[key: string]: any }> +): Array { + const processed: any[] = []; + + for (const item of content) { + if (item.type === "text" && item.text) { + const text = item.text; + const parts: Array<{ type: string; text?: string; props?: { latex: string } }> = []; + let lastIndex = 0; + + // Find all math matches + let match: RegExpExecArray | null; + MATH_REGEX.lastIndex = 0; // Reset regex + + while ((match = MATH_REGEX.exec(text)) !== null) { + const matchStart = match.index; + const matchEnd = match.index + match[0].length; + const latex = match[1].trim(); + + // Add text before the math + if (matchStart > lastIndex) { + const beforeText = text.substring(lastIndex, matchStart); + if (beforeText) { + parts.push({ + type: "text", + text: beforeText, + // Preserve other properties (styles, etc.) + ...Object.fromEntries( + Object.entries(item).filter(([key]) => key !== "type" && key !== "text") + ), + }); + } + } + + // Add inline math element + parts.push({ + type: "inlineMath", + props: { latex }, + }); + + lastIndex = matchEnd; + } + + // Add remaining text after last match + if (lastIndex < text.length) { + const afterText = text.substring(lastIndex); + if (afterText) { + parts.push({ + type: "text", + text: afterText, + // Preserve other properties + ...Object.fromEntries( + Object.entries(item).filter(([key]) => key !== "type" && key !== "text") + ), + }); + } + } + + // If we found math, use processed parts; otherwise keep original item + if (parts.length > 0) { + processed.push(...parts); + } else { + processed.push(item); + } + } else { + // Non-text items are kept as-is + processed.push(item); + } + } + + // Remove periods (and leading spaces before them) that immediately follow inline math + const cleaned: any[] = []; + for (let i = 0; i < processed.length; i++) { + const current = processed[i]; + const prev = i > 0 ? processed[i - 1] : null; + + // If current is text and previous is inlineMath, remove leading periods and spaces before them + if (current.type === "text" && current.text && prev && prev.type === "inlineMath") { + // Remove leading spaces followed by periods (e.g., " ." or ".") + // But preserve other text like " and " between math expressions + const cleanedText = current.text.replace(/^\s*\.+/, ''); + + // Only add if there's remaining text after cleaning + if (cleanedText) { + cleaned.push({ + ...current, + text: cleanedText, + }); + } + // If text becomes empty after cleaning (was just ". " or "."), skip it entirely + } else { + cleaned.push(current); + } + } + + return cleaned; +}