diff --git a/frontend/src/features/workspace/OutlinerTree.tsx b/frontend/src/features/workspace/OutlinerTree.tsx index 53ffade..6c6aca5 100644 --- a/frontend/src/features/workspace/OutlinerTree.tsx +++ b/frontend/src/features/workspace/OutlinerTree.tsx @@ -245,66 +245,167 @@ export const OutlinerTree = forwardRef(({ detectedSpaceStep = 4; } - // Parse line indentation and markdown prefixes - const parsed = rawLines.map((line) => { + // Parse lines into structured blocks + interface ParsedPasteItem { + type: BlockType; + checked?: boolean; + content: string; + indent: number; + } + + const parsed: ParsedPasteItem[] = []; + let currentItem: ParsedPasteItem | null = null; + let inCodeBlock = false; + let afterHeading = false; + + const pushCurrentItem = () => { + if (currentItem) { + currentItem.content = currentItem.content.trimEnd(); + parsed.push(currentItem); + currentItem = null; + } + }; + + for (let i = 0; i < rawLines.length; i++) { + const rawLine = rawLines[i]; + + // 1. Inside a fenced code block + if (inCodeBlock) { + if (currentItem) { + currentItem.content += '\n' + rawLine; + } + if (rawLine.trim().startsWith('```')) { + inCodeBlock = false; + } + continue; + } + + // 2. Starts a code fence + if (rawLine.trim().startsWith('```')) { + inCodeBlock = true; + if (afterHeading) { + afterHeading = false; + pushCurrentItem(); + currentItem = { + type: 'text', + content: rawLine, + indent: 0, + }; + } else if (currentItem) { + currentItem.content += (currentItem.content.endsWith('\n') ? '' : '\n') + rawLine; + } else { + currentItem = { + type: 'text', + content: rawLine, + indent: 0, + }; + } + continue; + } + + // Measure leading indent let tabs = 0; let spaces = 0; - let i = 0; - while (i < line.length) { - if (line[i] === '\t') { + let charIdx = 0; + while (charIdx < rawLine.length) { + if (rawLine[charIdx] === '\t') { tabs++; - i++; - } else if (line[i] === ' ') { + charIdx++; + } else if (rawLine[charIdx] === ' ') { spaces++; - i++; + charIdx++; } else { break; } } - const rawContent = line.slice(i); - const indent = tabs + Math.floor(spaces / detectedSpaceStep); + const strippedLine = rawLine.slice(charIdx); + const lineIndent = tabs + Math.floor(spaces / detectedSpaceStep); - // Todo: - [ ] or - [x] or * [ ] or * [x] - const todoMatch = rawContent.match(/^[-*]\s*\[([ xX])\]\s*(.*)$/); - if (todoMatch) { - return { - type: 'todo' as BlockType, - checked: todoMatch[1].toLowerCase() === 'x', - content: todoMatch[2], - indent, - }; + // Blank lines + if (strippedLine.trim() === '') { + if (currentItem) { + currentItem.content += '\n'; + } + continue; } // Headings: #, ##, ### - const h3Match = rawContent.match(/^###\s+(.*)$/); - if (h3Match) { - return { type: 'h3' as BlockType, content: h3Match[1], indent }; - } - const h2Match = rawContent.match(/^##\s+(.*)$/); - if (h2Match) { - return { type: 'h2' as BlockType, content: h2Match[1], indent }; - } - const h1Match = rawContent.match(/^#\s+(.*)$/); - if (h1Match) { - return { type: 'h1' as BlockType, content: h1Match[1], indent }; + const h3Match = strippedLine.match(/^###\s+(.*)$/); + const h2Match = strippedLine.match(/^##\s+(.*)$/); + const h1Match = strippedLine.match(/^#\s+(.*)$/); + if (h1Match || h2Match || h3Match) { + pushCurrentItem(); + afterHeading = true; + const type: BlockType = h1Match ? 'h1' : h2Match ? 'h2' : 'h3'; + const content = (h1Match || h2Match || h3Match)![1]; + parsed.push({ type, content, indent: lineIndent }); + continue; } - // Bullets: - or * or •, or numbered lists: 1. or 1) - const bulletMatch = rawContent.match(/^[-*•]\s+(.*)$/); - if (bulletMatch) { - return { type: 'text' as BlockType, content: bulletMatch[1], indent }; - } - const numMatch = rawContent.match(/^\d+[\.\)]\s+(.*)$/); - if (numMatch) { - return { type: 'text' as BlockType, content: numMatch[1], indent }; + // Dividers: ---, ***, ___ + if (/^(?:---|---|\*\*\*|___)\s*$/.test(strippedLine.trim())) { + pushCurrentItem(); + afterHeading = false; + parsed.push({ type: 'divider', content: '---', indent: lineIndent }); + continue; } - return { - type: 'text' as BlockType, - content: rawContent, - indent, - }; - }); + // Todo items: - [ ], - [x], * [ ], * [x] + const todoMatch = strippedLine.match(/^[-*]\s*\[([ xX])\]\s*(.*)$/); + if (todoMatch) { + pushCurrentItem(); + afterHeading = false; + parsed.push({ + type: 'todo', + checked: todoMatch[1].toLowerCase() === 'x', + content: todoMatch[2], + indent: lineIndent, + }); + continue; + } + + // Explicit bullet or numbered list: - item, * item, • item, 1. item, 1) item + const bulletMatch = strippedLine.match(/^[-*•]\s+(.*)$/); + const numMatch = strippedLine.match(/^\d+[\.\)]\s+(.*)$/); + if (bulletMatch || numMatch) { + pushCurrentItem(); + afterHeading = false; + parsed.push({ + type: 'text', + content: (bulletMatch ? bulletMatch[1] : numMatch![1]), + indent: lineIndent, + }); + continue; + } + + // Regular text line + if (afterHeading) { + // This starts the single bullet point after the heading + afterHeading = false; + pushCurrentItem(); + currentItem = { + type: 'text', + content: strippedLine, + indent: lineIndent, + }; + } else if (currentItem) { + if (currentItem.content.endsWith('\n')) { + currentItem.content += strippedLine; + } else { + currentItem.content += '\n' + strippedLine; + } + } else { + currentItem = { + type: 'text', + content: strippedLine, + indent: lineIndent, + }; + } + } + + pushCurrentItem(); + + if (parsed.length === 0) return; const line0 = parsed[0]; const baseIndent = line0.indent; diff --git a/frontend/src/lib/markdown.tsx b/frontend/src/lib/markdown.tsx index ab5528b..6044a50 100644 --- a/frontend/src/lib/markdown.tsx +++ b/frontend/src/lib/markdown.tsx @@ -8,7 +8,7 @@ import React from 'react'; * - `code` * - [label](url) */ -export function renderInlineMarkdown(text: string): React.ReactNode[] { +function renderInlineTokens(text: string): React.ReactNode[] { if (!text) return []; const regex = /(`[^`]+`|\*\*[^*]+\*\*|__[^_]+__|~~[^~]+~~|\*[^*]+\*|_[^_]+_|\[([^\]]+)\]\(([^)]+)\))/g; @@ -83,6 +83,55 @@ export function renderInlineMarkdown(text: string): React.ReactNode[] { return elements; } +/** + * Parses inline markdown and fenced code blocks: + * - ```lang\ncode\n``` + * - **bold** or __bold__ + * - *italic* or _italic_ + * - ~~strikethrough~~ + * - `code` + * - [label](url) + */ +export function renderInlineMarkdown(text: string): React.ReactNode[] { + if (!text) return []; + + if (text.includes('```')) { + const codeBlockRegex = /(```[a-zA-Z0-9_-]*\r?\n[\s\S]*?```|```[\s\S]*?```)/g; + const parts = text.split(codeBlockRegex); + const nodes: React.ReactNode[] = []; + + parts.forEach((part, partIdx) => { + if (part.startsWith('```') && part.endsWith('```')) { + const lines = part.split(/\r?\n/); + const firstLine = lines[0].slice(3).trim(); + const codeLines = lines.slice(1, lines.length - 1); + const code = codeLines.join('\n'); + nodes.push( +
+ {firstLine && ( +
+ {firstLine} +
+ )} +
+              {code}
+            
+
+ ); + } else if (part) { + nodes.push(...renderInlineTokens(part)); + } + }); + + return nodes; + } + + return renderInlineTokens(text); +} + export function MarkdownViewer({ content, className = '' }: { content: string; className?: string }) { if (!content) return null;