From ace0c1ba6bb19037e08c2febec3d8b829d8ebbc5 Mon Sep 17 00:00:00 2001 From: eldadfux Date: Sun, 26 Oct 2025 23:05:45 +0000 Subject: [PATCH 1/2] Implement rich text editing features in TextEditor component - Added toolbar with formatting options (bold, italic, underline, strikethrough, links, code, blockquotes, and lists). - Introduced markdown to HTML conversion and vice versa for better content handling. - Enhanced contentEditable area for improved user interaction and experience. - Implemented link editing functionality with a popup for managing links. - Refactored content change handling to support rich text features and maintain state consistency. --- src/routes/_protected/content.tsx | 587 +++++++++++++++++++++++++++--- 1 file changed, 529 insertions(+), 58 deletions(-) diff --git a/src/routes/_protected/content.tsx b/src/routes/_protected/content.tsx index 41a2ffb..5429eb5 100644 --- a/src/routes/_protected/content.tsx +++ b/src/routes/_protected/content.tsx @@ -24,7 +24,7 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigge import { ScrollArea } from '@/components/ui/scroll-area' import { Separator } from '@/components/ui/separator' import { toast } from '@/hooks/use-toast' -import { Image as ImageIcon, Plus, Trash2, Save, Video, MapPin, Type as TypeIcon, Upload, ArrowLeft, LogOut, GripVertical, Brain, Loader2, Heading1, Quote, Pin as PinIcon, FileText, Quote as QuoteIcon, Code, Bug, ChevronLeft, ChevronRight, MoreHorizontal, Copy, MessageCircle, Eye, EyeOff, Archive, BookOpen, FileDown, ExternalLink, ChevronUp, ChevronDown } from 'lucide-react' +import { Image as ImageIcon, Plus, Trash2, Save, Video, MapPin, Type as TypeIcon, Upload, ArrowLeft, LogOut, GripVertical, Brain, Loader2, Heading1, Quote, Pin as PinIcon, FileText, Quote as QuoteIcon, Code, Bug, ChevronLeft, ChevronRight, MoreHorizontal, Copy, MessageCircle, Eye, EyeOff, Archive, BookOpen, FileDown, ExternalLink, ChevronUp, ChevronDown, Bold, Italic, Underline, Strikethrough, Link as LinkIcon, Unlink, List, ListOrdered } from 'lucide-react' import { AgentChat } from '@/components/agent/agent-chat' import { AuthorSelector } from '@/components/author' import { CategorySelector } from '@/components/category' @@ -3995,79 +3995,212 @@ const QuoteEditor = memo(({ section, onLocalChange, disabled = false }: { sectio ) }) +// Helper functions for markdown conversion +const htmlToMarkdown = (html: string): string => { + if (!html || html.trim() === '') return '' + + const tempDiv = document.createElement('div') + tempDiv.innerHTML = html + + // Extract text content with formatting + const extractText = (node: Node, inList = false, listPrefix = ''): string => { + if (node.nodeType === Node.TEXT_NODE) { + return node.textContent || '' + } + + const element = node as HTMLElement + const tagName = element.tagName.toLowerCase() + const childrenStr = Array.from(element.childNodes) + .map(n => extractText(n, inList, listPrefix)) + .join('') + + switch (tagName) { + case 'ul': + const ulItems = Array.from(element.childNodes) + .filter(n => (n as HTMLElement).tagName === 'LI') + .map(n => { + const text = Array.from((n as HTMLElement).childNodes) + .map(c => extractText(c, false, '')) + .join('') + return '- ' + text.trim() + }) + .filter(text => text.trim()) + return ulItems.join('\n') + '\n\n' + + case 'ol': + const olItems = Array.from(element.childNodes) + .filter(n => (n as HTMLElement).tagName === 'LI') + .map((n, idx) => { + const text = Array.from((n as HTMLElement).childNodes) + .map(c => extractText(c, false, '')) + .join('') + return `${idx + 1}. ${text.trim()}` + }) + .filter(text => text.trim()) + return olItems.join('\n') + '\n\n' + + case 'li': + return inList ? listPrefix + childrenStr.trim() + '\n' : '- ' + childrenStr.trim() + + case 'strong': + case 'b': + return `**${childrenStr}**` + case 'em': + case 'i': + return `*${childrenStr}*` + case 'u': + return `${childrenStr}` + case 's': + case 'del': + return `~~${childrenStr}~~` + case 'code': + return `\`${childrenStr}\`` + case 'a': + const href = element.getAttribute('href') || '' + return `[${childrenStr}](${href})` + case 'blockquote': + return `> ${childrenStr.replace(/\n/g, '\n> ')}` + case 'br': + return '\n' + case 'p': + case 'div': + return inList ? childrenStr : childrenStr + '\n\n' + default: + return childrenStr + } + } + + const text = extractText(tempDiv) + return text.trim() +} + +const markdownToHtml = (markdown: string): string => { + if (!markdown || markdown.trim() === '') return '


' + + const lines = markdown.split('\n') + const processedLines: string[] = [] + + let inList = false + let listTag = '' + + for (let i = 0; i < lines.length; i++) { + const line = lines[i] + + // Headers + if (line.startsWith('#### ')) { + if (inList) { + processedLines.push('') + inList = false + } + processedLines.push(line.replace(/^#### (.+)$/, '

$1

')) + } else if (line.startsWith('### ')) { + if (inList) { + processedLines.push('') + inList = false + } + processedLines.push(line.replace(/^### (.+)$/, '

$1

')) + } else if (line.startsWith('## ')) { + if (inList) { + processedLines.push('') + inList = false + } + processedLines.push(line.replace(/^## (.+)$/, '

$1

')) + } else if (line.startsWith('# ')) { + if (inList) { + processedLines.push('') + inList = false + } + processedLines.push(line.replace(/^# (.+)$/, '

$1

')) + } + // Blockquotes + else if (line.startsWith('> ')) { + if (inList) { + processedLines.push('') + inList = false + } + processedLines.push(line.replace(/^> (.+)$/, '
$1
')) + } + // Lists + else if (line.startsWith('- ')) { + if (!inList) { + processedLines.push('