import React, { useState, useRef, useEffect } from 'react'; import { FileText, Plus, Upload, Download, Globe, AlertTriangle, Edit3, Eye, EyeOff, Columns, Type, Maximize2, Minimize2, Bold, Italic, Underline, List, ListOrdered, Link2, Code, Table, Minus, Heading, Quote, CheckSquare, ChevronUp, ChevronDown, FileDown } from 'lucide-react'; import ToolLayout from '../components/ToolLayout'; import CodeMirrorEditor from '../components/CodeMirrorEditor'; import SEO from '../components/SEO'; import RelatedTools from '../components/RelatedTools'; import { marked } from 'marked'; import { markedEmoji } from 'marked-emoji'; import DOMPurify from 'dompurify'; import hljs from 'highlight.js'; import html2pdf from 'html2pdf.js'; import 'highlight.js/styles/github-dark.css'; import '../styles/markdown-preview.css'; const MarkdownEditor = () => { const [markdownText, setMarkdownText] = useState(''); // Sync markdown data to localStorage for navigation guard useEffect(() => { localStorage.setItem('markdownEditorData', markdownText); }, [markdownText]); // State management following ObjectEditor pattern const [activeTab, setActiveTab] = useState('create'); const [inputText, setInputText] = useState(''); const [error, setError] = useState(''); // Default to 'editor' on mobile, 'split' on desktop const [viewMode, setViewMode] = useState(() => { return window.innerWidth < 1024 ? 'editor' : 'split'; }); const [isFullscreen, setIsFullscreen] = useState(false); const [fetchUrl, setFetchUrl] = useState(''); const [fetching, setFetching] = useState(false); const [showHeadingDropdown, setShowHeadingDropdown] = useState(false); const [createNewCompleted, setCreateNewCompleted] = useState(false); const [showInputChangeModal, setShowInputChangeModal] = useState(false); const [pendingTabChange, setPendingTabChange] = useState(null); const fileInputRef = useRef(null); const [pasteCollapsed, setPasteCollapsed] = useState(false); const [pasteDataSummary, setPasteDataSummary] = useState(null); const [urlDataSummary, setUrlDataSummary] = useState(null); const [fileDataSummary, setFileDataSummary] = useState(null); const [usageTipsExpanded, setUsageTipsExpanded] = useState(false); // Configure marked with custom renderer for code blocks useEffect(() => { const renderer = new marked.Renderer(); // Custom code block renderer with header and copy button renderer.code = function(token) { // In marked v4+, parameters come as an object const codeString = String(token.text || token || ''); const language = token.lang || ''; const normalizedLang = language ? language.toLowerCase().trim() : ''; let highlightedCode = codeString; // Apply syntax highlighting if (normalizedLang && hljs.getLanguage(normalizedLang)) { try { const result = hljs.highlight(codeString, { language: normalizedLang }); highlightedCode = result.value; } catch (e) { highlightedCode = codeString; } } else { try { const result = hljs.highlightAuto(codeString); highlightedCode = result.value; } catch (e) { highlightedCode = codeString; } } const displayLang = normalizedLang || 'text'; // Create a unique ID for this code block const blockId = 'code-' + Math.random().toString(36).substr(2, 9); return `
${displayLang}
${highlightedCode}
`; }; marked.setOptions({ gfm: true, breaks: true, renderer: renderer }); // Enable GFM extensions including task lists marked.use({ gfm: true, breaks: true }); // Enable emoji support marked.use(markedEmoji({ emojis: {}, // Uses default emoji set unicode: true // Use unicode emojis })); }, []); // Parse markdown to HTML with custom underline support const parseMarkdown = (markdown) => { try { // Convert __text__ to text before parsing (custom underline syntax) // But preserve __ at start of line (which is for bold in some contexts) let processed = markdown.replace(/(?$1'); const html = marked.parse(processed || ''); return DOMPurify.sanitize(html, { ADD_TAGS: ['u', 'button', 'input'], ADD_ATTR: ['data-code-id', 'title', 'id', 'type', 'checked', 'disabled'] }); } catch (e) { return '

Error parsing markdown

'; } }; // Calculate statistics const calculateStats = () => { if (!markdownText) { return { words: 0, characters: 0, lines: 0, readingTime: 0 }; } const words = markdownText.trim().split(/\s+/).length; const characters = markdownText.length; const lines = markdownText.split('\n').length; const readingTime = Math.ceil(words / 200); return { words, characters, lines, readingTime }; }; const stats = calculateStats(); // Add event delegation for copy buttons and close dropdown on outside click useEffect(() => { const handleClick = (e) => { // Handle copy button clicks const button = e.target.closest('.code-block-copy'); if (button) { const codeId = button.getAttribute('data-code-id'); const codeElement = document.getElementById(codeId); if (codeElement) { const code = codeElement.textContent; navigator.clipboard.writeText(code).then(() => { const originalText = button.textContent; button.textContent = 'Copied!'; setTimeout(() => { button.textContent = originalText; }, 2000); }).catch(err => { // Failed to copy }); } return; } // Close heading dropdown if clicking outside if (showHeadingDropdown && !e.target.closest('.relative')) { setShowHeadingDropdown(false); } }; document.addEventListener('click', handleClick); return () => document.removeEventListener('click', handleClick); }, [showHeadingDropdown]); // Toolbar formatting functions - proper CodeMirror integration with toggle support const insertMarkdown = (before, after = '', placeholder = 'text', skipPlaceholder = false) => { // Get CodeMirror view from the DOM const editorElement = document.querySelector('.cm-editor'); if (!editorElement) return; const view = editorElement.cmView?.view; if (!view) return; const state = view.state; const selection = state.selection.main; // Get selected text or use placeholder (unless skipPlaceholder is true) const selectedText = state.doc.sliceString(selection.from, selection.to) || (skipPlaceholder ? '' : placeholder); // Check if text is already formatted (toggle support) const beforeLen = before.length; const afterLen = after.length; const expandedFrom = Math.max(0, selection.from - beforeLen); const expandedTo = Math.min(state.doc.length, selection.to + afterLen); const expandedText = state.doc.sliceString(expandedFrom, expandedTo); // Check if already formatted const isFormatted = expandedText.startsWith(before) && expandedText.endsWith(after); if (isFormatted && selectedText) { // Remove formatting view.dispatch({ changes: { from: expandedFrom, to: expandedTo, insert: selectedText }, selection: { anchor: expandedFrom, head: expandedFrom + selectedText.length } }); } else { // Add formatting const formatted = `${before}${selectedText}${after}`; view.dispatch({ changes: { from: selection.from, to: selection.to, insert: formatted }, selection: { anchor: selection.from + before.length, head: selection.from + before.length + selectedText.length } }); } // Focus back to editor view.focus(); }; // Heading insertion helper const insertHeading = (level) => { const prefix = '#'.repeat(level) + ' '; insertMarkdown(prefix, '', 'Heading'); setShowHeadingDropdown(false); }; const toolbarButtons = [ { icon: Heading, label: 'Heading', action: () => setShowHeadingDropdown(!showHeadingDropdown), isDropdown: true, group: 'formatter' }, { icon: Bold, label: 'Bold', action: () => insertMarkdown('**', '**', 'bold text'), group: 'formatter' }, { icon: Italic, label: 'Italic', action: () => insertMarkdown('*', '*', 'italic text'), group: 'formatter' }, { icon: Underline, label: 'Underline', action: () => insertMarkdown('__', '__', 'underlined text'), group: 'formatter' }, { icon: Quote, label: 'Quote', action: () => insertMarkdown('> ', '', 'quote'), group: 'formatter' }, { icon: Code, label: 'Code Block', action: () => insertMarkdown('\n```\n', '\n```\n', 'code'), group: 'formatter' }, { icon: Link2, label: 'Link', action: () => insertMarkdown('[', '](url)', 'link text'), group: 'formatter' }, { icon: List, label: 'Bullet List', action: () => insertMarkdown('- ', '', 'list item'), group: 'list' }, { icon: ListOrdered, label: 'Numbered List', action: () => insertMarkdown('1. ', '', 'list item'), group: 'list' }, { icon: CheckSquare, label: 'Task List', action: () => insertMarkdown('- [ ] ', '', 'task'), group: 'list' }, { icon: Minus, label: 'Divider', action: () => insertMarkdown('\n---\n', '', '', true), group: 'element' }, { icon: Table, label: 'Table', action: () => insertMarkdown('\n| Header 1 | Header 2 |\n|----------|----------|\n| Cell 1 | Cell 2 |', '', '', true), group: 'element' }, ]; // Sample markdown const sampleMarkdown = `# Welcome to Markdown Editor A powerful, privacy-first markdown editor with live preview. ## Features - **Live Preview** - See your markdown rendered in real-time - **Syntax Highlighting** - Beautiful code blocks with syntax highlighting - **Export Options** - Export to Markdown, HTML, or Plain Text - **GitHub Flavored Markdown** - Full GFM support ## Code Examples with Syntax Highlighting JavaScript: \`\`\`javascript function greet(name) { console.log(\`Hello, \${name}!\`); } greet('World'); \`\`\` Bash: \`\`\`bash #!/bin/bash echo "Hello, World!" npm install \`\`\` JSON: \`\`\`json { "name": "markdown-editor", "version": "1.0.0", "features": ["live-preview", "syntax-highlighting"] } \`\`\` PHP: \`\`\`php \`\`\` ## Tables | Feature | Status | |---------|--------| | Live Preview | ✅ | | Export | ✅ | | Privacy-First | ✅ | ## Links and Images [Visit our homepage](/) > This is a blockquote. Perfect for highlighting important information. --- **Bold text**, *italic text*, __underlined text__, and \`inline code\` are all supported! ### Lists 1. First item 2. Second item 3. Third item - Unordered item - Another item - Nested item - Another nested item Happy writing! 🚀`; // Markdown Templates const templates = { sample: sampleMarkdown, readme: `# Project Name ## Description A brief description of what this project does and who it's for. ## Features - ✨ Feature 1 - 🚀 Feature 2 - 💡 Feature 3 ## Installation \`\`\`bash npm install project-name \`\`\` ## Usage \`\`\`javascript const project = require('project-name'); project.doSomething(); \`\`\` ## API Reference ### \`functionName(param)\` Description of what the function does. **Parameters:** - \`param\` (string): Description of parameter **Returns:** Description of return value ## Contributing Contributions are welcome! Please feel free to submit a Pull Request. ## License MIT License - see LICENSE file for details ## Contact - GitHub: [@username](https://github.com/username) - Email: email@example.com`, blog: `# Blog Post Title **Published:** ${new Date().toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' })} **Author:** Your Name **Tags:** #tag1 #tag2 #tag3 --- ## Introduction Start with a compelling introduction that hooks your readers and explains what they'll learn from this post. ## Main Content ### Section 1: Key Point Explain your first main point here. Use examples and code snippets to illustrate: \`\`\`javascript // Example code const example = "This makes your point clear"; console.log(example); \`\`\` ### Section 2: Another Key Point Continue with your second main point. Break down complex ideas into digestible chunks. > 💡 **Pro Tip:** Use blockquotes to highlight important insights or tips. ### Section 3: Practical Application Show readers how to apply what they've learned: 1. Step one 2. Step two 3. Step three ## Conclusion Summarize the key takeaways and provide next steps or additional resources. ## Resources - [Resource 1](https://example.com) - [Resource 2](https://example.com) - [Resource 3](https://example.com) --- *Thanks for reading! If you found this helpful, please share it with others.*`, documentation: `# API Documentation ## Overview Brief description of the API and its purpose. ## Base URL \`\`\` https://api.example.com/v1 \`\`\` ## Authentication All API requests require authentication using an API key: \`\`\`bash curl -H "Authorization: Bearer YOUR_API_KEY" https://api.example.com/v1/endpoint \`\`\` ## Endpoints ### GET /users Retrieve a list of users. **Parameters:** | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | page | integer | No | Page number (default: 1) | | limit | integer | No | Items per page (default: 10) | **Response:** \`\`\`json { "data": [ { "id": 1, "name": "John Doe", "email": "john@example.com" } ], "meta": { "page": 1, "total": 100 } } \`\`\` ### POST /users Create a new user. **Request Body:** \`\`\`json { "name": "Jane Doe", "email": "jane@example.com", "password": "secure_password" } \`\`\` **Response:** \`\`\`json { "id": 2, "name": "Jane Doe", "email": "jane@example.com", "created_at": "2025-01-01T00:00:00Z" } \`\`\` ## Error Codes | Code | Description | |------|-------------| | 400 | Bad Request - Invalid parameters | | 401 | Unauthorized - Invalid API key | | 404 | Not Found - Resource doesn't exist | | 500 | Internal Server Error | ## Rate Limiting - 1000 requests per hour per API key - Rate limit info included in response headers ## Support For support, email support@example.com or visit our [Help Center](https://help.example.com).`, meeting: `# Meeting Notes **Date:** ${new Date().toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' })} **Time:** [Start Time] - [End Time] **Location:** [Location/Video Call Link] ## Attendees - [ ] Person 1 - [ ] Person 2 - [ ] Person 3 ## Agenda 1. Review previous action items 2. Topic 1 3. Topic 2 4. Any other business --- ## Discussion ### Topic 1: [Topic Name] **Presenter:** [Name] **Key Points:** - Point 1 - Point 2 - Point 3 **Decisions Made:** - Decision 1 - Decision 2 ### Topic 2: [Topic Name] **Presenter:** [Name] **Key Points:** - Point 1 - Point 2 ## Action Items | Task | Owner | Due Date | Status | |------|-------|----------|--------| | Task 1 | Person A | 2025-01-15 | 🔄 In Progress | | Task 2 | Person B | 2025-01-20 | ⏳ Pending | | Task 3 | Person C | 2025-01-10 | ✅ Complete | ## Next Meeting **Date:** [Next Meeting Date] **Agenda Items:** - Follow up on action items - [Other topics] --- *Notes prepared by: [Your Name]*`, changelog: `# Changelog All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] ### Added - New features that have been added ### Changed - Changes in existing functionality ### Deprecated - Soon-to-be removed features ### Removed - Removed features ### Fixed - Bug fixes ### Security - Security improvements ## [1.0.0] - ${new Date().toISOString().split('T')[0]} ### Added - Initial release - Core functionality implemented - Documentation added - Tests added ### Changed - Updated dependencies - Improved performance ### Fixed - Fixed bug #123 - Resolved issue with feature X ## [0.2.0] - 2024-12-01 ### Added - Feature A - Feature B ### Changed - Refactored module X - Updated UI components ### Fixed - Fixed critical bug in authentication - Resolved memory leak ## [0.1.0] - 2024-11-01 ### Added - Initial beta release - Basic features implemented - Project structure established --- [Unreleased]: https://github.com/username/repo/compare/v1.0.0...HEAD [1.0.0]: https://github.com/username/repo/compare/v0.2.0...v1.0.0 [0.2.0]: https://github.com/username/repo/compare/v0.1.0...v0.2.0 [0.1.0]: https://github.com/username/repo/releases/tag/v0.1.0` }; const [selectedTemplate, setSelectedTemplate] = useState('sample'); // Helper function to check if user has data that would be lost const hasUserData = () => { return markdownText.trim().length > 0; }; // Check if current data has been modified from initial state const hasModifiedData = () => { if (!markdownText.trim()) return false; if (markdownText === sampleMarkdown) return false; return true; }; // Handle tab change with confirmation if data exists const handleTabChange = (newTab) => { if (newTab === 'create' && activeTab !== 'create') { if (hasModifiedData()) { setPendingTabChange(newTab); setShowInputChangeModal(true); } else { setActiveTab(newTab); setCreateNewCompleted(false); } } else if (hasUserData() && activeTab !== newTab) { setPendingTabChange(newTab); setShowInputChangeModal(true); } else { setActiveTab(newTab); if (newTab === 'create' && createNewCompleted) { setCreateNewCompleted(false); } } }; // Clear all data function const clearAllData = () => { setMarkdownText(''); setInputText(''); setError(''); setCreateNewCompleted(false); setPasteCollapsed(false); setPasteDataSummary(null); setUrlDataSummary(null); setFileDataSummary(null); }; // Confirm input method change and clear data const confirmInputChange = () => { if (pendingTabChange === 'create_empty') { clearAllData(); setMarkdownText(''); setCreateNewCompleted(true); } else if (pendingTabChange === 'create_sample') { clearAllData(); setMarkdownText(templates[selectedTemplate]); setCreateNewCompleted(true); } else { clearAllData(); setActiveTab(pendingTabChange); if (pendingTabChange === 'create') { setCreateNewCompleted(false); } } setShowInputChangeModal(false); setPendingTabChange(null); }; // Cancel input method change const cancelInputChange = () => { setShowInputChangeModal(false); setPendingTabChange(null); }; // Handle Parse Markdown button click const handleParseMarkdown = () => { if (!inputText.trim()) { setError('Please enter some markdown text'); setPasteCollapsed(false); return; } setMarkdownText(inputText); setError(''); setCreateNewCompleted(true); setPasteDataSummary({ format: 'Markdown', size: inputText.length, lines: inputText.split('\n').length }); setPasteCollapsed(true); }; // Handle file import (auto-load, same as ObjectEditor) const handleFileUpload = (event) => { const file = event.target.files[0]; if (!file) return; const reader = new FileReader(); reader.onload = (e) => { let content = e.target.result; let format = 'Markdown'; // Detect file type and convert if needed if (file.name.endsWith('.html') || file.name.endsWith('.htm')) { // Extract content from HTML body if it's a full HTML document const bodyMatch = content.match(/]*>([\s\S]*)<\/body>/i); if (bodyMatch) { content = bodyMatch[1]; } // Convert HTML to markdown (basic conversion) content = htmlToMarkdown(content); format = 'HTML (converted)'; } else if (file.name.endsWith('.txt')) { // Plain text - treat as markdown-ready content format = 'Plain Text'; } setMarkdownText(content); setActiveTab('open'); setCreateNewCompleted(true); setError(''); // Update file data summary setFileDataSummary({ format: format, size: content.length, lines: content.split('\n').length, filename: file.name }); }; reader.onerror = () => { setError('Failed to read file'); }; reader.readAsText(file); }; // Fetch markdown from URL const handleFetchFromURL = async () => { if (!fetchUrl.trim()) { setError('Please enter a URL'); return; } setFetching(true); setError(''); try { let urlToFetch = fetchUrl.trim(); // Convert GitHub URLs to raw URLs if (urlToFetch.includes('github.com') && !urlToFetch.includes('raw.githubusercontent.com')) { urlToFetch = urlToFetch .replace('github.com', 'raw.githubusercontent.com') .replace('/blob/', '/'); } // Try direct fetch first let response; try { response = await fetch(urlToFetch); } catch (corsError) { // If CORS error, try with CORS proxy response = await fetch(`https://api.allorigins.win/raw?url=${encodeURIComponent(urlToFetch)}`); } if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`); } const text = await response.text(); if (!text || text.trim().length === 0) { throw new Error('URL returned empty content'); } // Detect format let format = 'Markdown'; if (urlToFetch.endsWith('.html') || urlToFetch.endsWith('.htm')) { format = 'HTML (converted)'; const converted = htmlToMarkdown(text); setMarkdownText(converted); } else { setMarkdownText(text); } setActiveTab('url'); setCreateNewCompleted(true); setError(''); // Update URL data summary setUrlDataSummary({ format: format, size: text.length, lines: text.split('\n').length, url: urlToFetch }); } catch (err) { setError(`Failed to fetch from URL: ${err.message}`); } finally { setFetching(false); } }; // HTML to Markdown converter - designed to reverse our exact export format const htmlToMarkdown = (html) => { // Create a temporary DOM element to parse HTML properly const tempDiv = document.createElement('div'); tempDiv.innerHTML = html; // Remove script and style tags tempDiv.querySelectorAll('script, style').forEach(el => el.remove()); // Process our custom code-block-wrapper structure tempDiv.querySelectorAll('.code-block-wrapper').forEach(wrapper => { const langSpan = wrapper.querySelector('.code-block-language'); const codeElement = wrapper.querySelector('code'); if (codeElement) { const language = langSpan ? langSpan.textContent.trim() : ''; // Get the text content directly (this preserves the actual code) let code = codeElement.textContent; // Create markdown code block const codeBlock = document.createTextNode(`\n\`\`\`${language}\n${code}\n\`\`\`\n\n`); wrapper.parentNode.replaceChild(codeBlock, wrapper); } }); // Handle regular code blocks (without our wrapper) tempDiv.querySelectorAll('pre > code').forEach(codeElement => { const pre = codeElement.parentElement; const classMatch = codeElement.className.match(/language-(\w+)/); const language = classMatch ? classMatch[1] : ''; let code = codeElement.textContent; // Create markdown code block const codeBlock = document.createTextNode(`\n\`\`\`${language}\n${code}\n\`\`\`\n\n`); pre.parentNode.replaceChild(codeBlock, pre); }); // Get the processed HTML let markdown = tempDiv.innerHTML; // Headers markdown = markdown.replace(/]*>([\s\S]*?)<\/h1>/gi, '# $1\n\n'); markdown = markdown.replace(/]*>([\s\S]*?)<\/h2>/gi, '## $1\n\n'); markdown = markdown.replace(/]*>([\s\S]*?)<\/h3>/gi, '### $1\n\n'); markdown = markdown.replace(/]*>([\s\S]*?)<\/h4>/gi, '#### $1\n\n'); markdown = markdown.replace(/]*>([\s\S]*?)<\/h5>/gi, '##### $1\n\n'); markdown = markdown.replace(/]*>([\s\S]*?)<\/h6>/gi, '###### $1\n\n'); // Bold and Italic markdown = markdown.replace(/]*>([\s\S]*?)<\/strong>/gi, '**$1**'); markdown = markdown.replace(/]*>([\s\S]*?)<\/b>/gi, '**$1**'); markdown = markdown.replace(/]*>([\s\S]*?)<\/em>/gi, '*$1*'); markdown = markdown.replace(/]*>([\s\S]*?)<\/i>/gi, '*$1*'); markdown = markdown.replace(/]*>([\s\S]*?)<\/u>/gi, '__$1__'); // Links markdown = markdown.replace(/]*href="([^"]*)"[^>]*>([\s\S]*?)<\/a>/gi, '[$2]($1)'); // Images markdown = markdown.replace(/]*src="([^"]*)"[^>]*alt="([^"]*)"[^>]*>/gi, '![$2]($1)'); markdown = markdown.replace(/]*alt="([^"]*)"[^>]*src="([^"]*)"[^>]*>/gi, '![$1]($2)'); // Inline code (must come after code blocks) markdown = markdown.replace(/]*>([\s\S]*?)<\/code>/gi, '`$1`'); // Tables markdown = markdown.replace(/]*>([\s\S]*?)<\/table>/gi, (match, content) => { let tableMarkdown = '\n'; const rows = content.match(/]*>([\s\S]*?)<\/tr>/gi) || []; rows.forEach((row, index) => { const cells = row.match(/]*>([\s\S]*?)<\/t[hd]>/gi) || []; const cellContents = cells.map(cell => cell.replace(/<[^>]+>/g, '').trim()); tableMarkdown += '| ' + cellContents.join(' | ') + ' |\n'; // Add separator after header row if (index === 0) { tableMarkdown += '| ' + cellContents.map(() => '---').join(' | ') + ' |\n'; } }); return tableMarkdown + '\n'; }); // Lists - handle nested lists markdown = markdown.replace(/]*>([\s\S]*?)<\/li>/gi, (match, content) => { content = content.trim(); return '- ' + content + '\n'; }); markdown = markdown.replace(/]*>([\s\S]*?)<\/ul>/gi, '$1\n'); markdown = markdown.replace(/]*>([\s\S]*?)<\/ol>/gi, '$1\n'); // Blockquotes markdown = markdown.replace(/]*>([\s\S]*?)<\/blockquote>/gi, (match, content) => { // Remove HTML tags from content first content = content.replace(/<[^>]+>/g, ''); return '\n' + content.split('\n').filter(line => line.trim()).map(line => '> ' + line.trim()).join('\n') + '\n\n'; }); // Paragraphs and breaks markdown = markdown.replace(/]*>([\s\S]*?)<\/p>/gi, (match, content) => { content = content.trim(); return content + '\n\n'; }); markdown = markdown.replace(//gi, '\n'); markdown = markdown.replace(//gi, '\n---\n\n'); // Remove remaining HTML tags markdown = markdown.replace(/<[^>]+>/g, ''); // Decode HTML entities (that might still be in non-code content) markdown = markdown.replace(/</g, '<'); markdown = markdown.replace(/>/g, '>'); markdown = markdown.replace(/&/g, '&'); markdown = markdown.replace(/"/g, '"'); markdown = markdown.replace(/'/g, "'"); markdown = markdown.replace(/ /g, ' '); // Clean up extra whitespace gently // Remove multiple consecutive blank lines (3+ becomes 2) markdown = markdown.replace(/\n{3,}/g, '\n\n'); // Remove leading spaces from each line (but preserve code block indentation) let inCodeBlock = false; markdown = markdown.split('\n').map(line => { // Check if this line starts or ends a code block if (line.trim().startsWith('```')) { inCodeBlock = !inCodeBlock; return line.trimStart(); // Trim the ``` line itself } // If we're inside a code block, preserve indentation if (inCodeBlock) { return line; } // Outside code blocks, remove leading spaces return line.trimStart(); }).join('\n'); // Remove leading/trailing whitespace from the entire document markdown = markdown.trim(); return markdown; }; // Download file helper const downloadFile = (content, filename, mimeType) => { const blob = new Blob([content], { type: mimeType }); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = filename; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); }; // Export handlers const handleExportMarkdown = () => { if (!markdownText.trim()) { setError('No content to export'); return; } downloadFile(markdownText, 'document.md', 'text/markdown'); }; const handleExportHTML = () => { if (!markdownText.trim()) { setError('No content to export'); return; } const html = parseMarkdown(markdownText); const fullHTML = ` Markdown Document ${html} `; downloadFile(fullHTML, 'document.html', 'text/html'); }; const handleExportHTMLContent = () => { if (!markdownText.trim()) { setError('No content to export'); return; } let html = parseMarkdown(markdownText); // Clean up the HTML content by removing code-block wrappers const tempDiv = document.createElement('div'); tempDiv.innerHTML = html; // Remove code-block-header and code-block-wrapper elements tempDiv.querySelectorAll('.code-block-header').forEach(el => el.remove()); tempDiv.querySelectorAll('.code-block-wrapper').forEach(wrapper => { while (wrapper.firstChild) { wrapper.parentNode.insertBefore(wrapper.firstChild, wrapper); } wrapper.remove(); }); html = tempDiv.innerHTML; downloadFile(html, 'content.html', 'text/html'); }; const handleExportPlainText = () => { if (!markdownText.trim()) { setError('No content to export'); return; } // Export as markdown-ready plain text (keep markdown syntax) // This allows users to copy/paste and re-import without losing formatting downloadFile(markdownText, 'document.txt', 'text/plain'); }; const handleCopyToClipboard = () => { if (!markdownText.trim()) { setError('No content to copy'); return; } navigator.clipboard.writeText(markdownText).then(() => { // Show success feedback (you can add a toast notification here) setError(''); // Clear any errors }).catch(() => { setError('Failed to copy to clipboard'); }); }; const handleExportPDF = () => { if (!markdownText.trim()) { setError('No content to export'); return; } // Create the content element with rendered markdown const element = document.createElement('div'); element.innerHTML = parseMarkdown(markdownText); // Remove code block headers (language + copy button) const codeHeaders = element.querySelectorAll('.code-block-header'); codeHeaders.forEach(header => header.remove()); // Remove copy buttons from code blocks const copyButtons = element.querySelectorAll('button[title="Copy code"]'); copyButtons.forEach(btn => btn.remove()); // Add comprehensive PDF styles inline const styleEl = document.createElement('style'); styleEl.textContent = ` .pdf-content { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif; font-size: 12pt; line-height: 1.6; color: #24292e; padding: 20px; } .pdf-content h1 { font-size: 24pt; font-weight: 600; margin-top: 0; margin-bottom: 16px; padding-bottom: 8px; border-bottom: 1px solid #eaecef; } .pdf-content h1:first-child { margin-top: 0; } .pdf-content h2 { font-size: 20pt; font-weight: 600; margin-top: 24px; margin-bottom: 16px; padding-bottom: 8px; border-bottom: 1px solid #eaecef; } .pdf-content h3 { font-size: 16pt; font-weight: 600; margin-top: 24px; margin-bottom: 16px; } .pdf-content h4 { font-size: 14pt; font-weight: 600; margin-top: 16px; margin-bottom: 16px; } .pdf-content h5, .pdf-content h6 { font-size: 12pt; font-weight: 600; margin-top: 16px; margin-bottom: 16px; } .pdf-content p { margin-top: 0; margin-bottom: 16px; line-height: 1.4; } .pdf-content ul, .pdf-content ol { list-style: none; margin-top: 0; margin-bottom: 16px; padding-left: 0; padding-bottom: 8px; } .pdf-content li { display: flex; align-items: flex-start; flex-wrap: wrap; margin-bottom: 8px; padding-left: 0; line-height: 1.6; } /* Nested lists should break to new line */ .pdf-content li > ul, .pdf-content li > ol { width: 100%; margin-top: 8px; margin-bottom: 0; padding-left: 0; } /* Custom bullet for ul */ .pdf-content ul > li::before { content: "•"; display: inline-block; width: 1.2em; margin-right: 0.6em; margin-left: 1.5em; font-size: 1em; color: #24292e; flex-shrink: 0; line-height: 1.6; } /* Custom numbering for ol */ .pdf-content ol { counter-reset: custom-num; } .pdf-content ol > li { counter-increment: custom-num; } .pdf-content ol > li::before { content: counter(custom-num) "."; display: inline-block; width: 1.8em; margin-right: 0.5em; margin-left: 1em; font-size: 1em; color: #24292e; flex-shrink: 0; text-align: right; line-height: 1.6; } /* Nested ul inside ol - bullets with proper indentation */ .pdf-content ol > li > ul > li::before { content: "•"; margin-left: 3em; width: 1.2em; } /* Nested ul inside ul - circle bullets (level 2) */ .pdf-content ul ul > li::before { content: "○"; margin-left: 3em; width: 1.2em; } /* Nested ul inside nested ul (inside ol) - circle bullets */ .pdf-content ol > li > ul ul > li::before { content: "○"; margin-left: 4.5em; width: 1.2em; } /* Deeply nested ul - square bullets (level 3) */ .pdf-content ul ul ul > li::before { content: "▪"; margin-left: 4.5em; width: 1.2em; font-size: 0.8em; } .pdf-content table { border-collapse: collapse; width: 100%; margin-bottom: 16px; } .pdf-content table th, .pdf-content table td { padding: 8px 12px 20px 12px; border: 1px solid #dfe2e5; text-align: left; vertical-align: middle; line-height: 1.4; } .pdf-content table th { background-color: #f6f8fa; font-weight: 600; } .pdf-content table tr:nth-child(even) { background-color: #f6f8fa; } .pdf-content code { background-color: #f6f8fa; padding: 2px 6px; border-radius: 3px; font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace; font-size: 11pt; } .pdf-content pre { background-color: #f6f8fa; padding: 16px 16px 24px 16px; border-radius: 6px; overflow: visible; margin-bottom: 16px; page-break-inside: avoid; min-height: 60px; } .pdf-content pre code { background-color: transparent; padding: 0 0 8px 0; font-size: 10pt; line-height: 1.5; display: block; } .pdf-content blockquote { padding: 8px 16px 20px 16px; margin: 0 0 16px 0; border-left: 4px solid #dfe2e5; color: #6a737d; line-height: 1.4; } .pdf-content blockquote p { margin: 0; line-height: 1.4; } .pdf-content hr { height: 2px; padding: 0; margin: 24px 0; background-color: #e1e4e8; border: 0; } .pdf-content a { color: #0366d6; text-decoration: none; } .pdf-content strong { font-weight: 600; } .pdf-content img { max-width: 100%; height: auto; } .pdf-content input[type="checkbox"] { margin-right: 8px; } `; element.className = 'pdf-content'; // Create wrapper with styles const wrapper = document.createElement('div'); wrapper.appendChild(styleEl); wrapper.appendChild(element); const opt = { margin: [15, 15, 15, 15], filename: 'document.pdf', image: { type: 'jpeg', quality: 0.98 }, html2canvas: { scale: 2, useCORS: true, logging: false, letterRendering: true, backgroundColor: '#ffffff' }, jsPDF: { unit: 'mm', format: 'a4', orientation: 'portrait', compress: true }, pagebreak: { mode: ['avoid-all', 'css', 'legacy'] } }; html2pdf().set(opt).from(wrapper).save().then(() => { setError(''); }).catch((err) => { setError('Failed to generate PDF'); }); }; return ( <> {/* Input Section - Always visible */}

Get Started

{/* Tab Navigation */}
{/* Tab Content */} {(activeTab !== 'create' || !createNewCompleted) && (
{/* Create New Tab Content */} {activeTab === 'create' && ( <> {!createNewCompleted ? (

Create New Markdown Document

Choose how you'd like to begin writing

💡 Tip: You can always import markdown later using the URL, Paste, or Open tabs.

) : (
✓ Document ready: {markdownText ? `${stats.words} words, ${stats.characters} characters, ${stats.lines} lines` : 'Empty document'}
)} )} {/* URL Tab Content */} {activeTab === 'url' && (
{urlDataSummary && (
✓ Markdown loaded: ({urlDataSummary.size.toLocaleString()} chars, {urlDataSummary.lines} lines)
)} {!urlDataSummary && (
setFetchUrl(e.target.value)} onKeyPress={(e) => e.key === 'Enter' && !fetching && fetchUrl.trim() && handleFetchFromURL()} placeholder="https://raw.githubusercontent.com/user/repo/main/README.md" className="tool-input w-full" disabled={fetching} />

Enter a URL to a markdown file (GitHub raw, Gist, Pastebin, etc.)

)}
)} {/* Paste Tab Content */} {activeTab === 'paste' && ( pasteCollapsed ? (
✓ Markdown loaded: {pasteDataSummary.format} ({pasteDataSummary.size.toLocaleString()} chars, {pasteDataSummary.lines} lines)
) : (
{error && (

Error: {error}

)}
Paste markdown text
) )} {/* Open Tab Content */} {activeTab === 'open' && ( fileDataSummary ? (
✓ File loaded: {fileDataSummary.format} ({fileDataSummary.size.toLocaleString()} chars, {fileDataSummary.lines} lines) - {fileDataSummary.filename}
) : (

🔒 Privacy: Your data stays in your browser. We don't store or upload anything.

) )}
)}
{/* Input Method Change Confirmation Modal */} {showInputChangeModal && ( )} {/* Editor Section */} {(activeTab !== 'create' || createNewCompleted) && (
{/* Editor Header - Sticky */}

Markdown Editor

{/* Statistics */}
{stats.words} words {stats.characters} chars {stats.lines} lines {stats.readingTime} min read
{/* View Mode Controls */}
{/* Hide split button on mobile (< lg) */}
{/* Markdown Toolbar */} {(viewMode === 'editor' || viewMode === 'split') && (
{toolbarButtons.map((btn, idx) => { const Icon = btn.icon; const prevGroup = idx > 0 ? toolbarButtons[idx - 1].group : null; const showSeparator = idx > 0 && btn.group !== prevGroup; return ( {/* Group Separator */} {showSeparator && (
)}
{/* Tooltip */}
{btn.label}
{/* Heading Dropdown */} {btn.isDropdown && showHeadingDropdown && (
{[1, 2, 3, 4, 5, 6].map(level => ( ))}
)}
); })}
)} {/* Editor Content */}
{/* Markdown Editor */} {(viewMode === 'editor' || viewMode === 'split') && (
)} {/* Preview */} {(viewMode === 'preview' || viewMode === 'split') && (
{markdownText ? (
) : (

Preview will appear here

)}
)}
)} {/* Export Section */} {markdownText && (

Export Options

Download your markdown in different formats

{/* Export as Markdown */} {/* Export as PDF */} {/* Export as Full HTML */} {/* Export as HTML Content Only */} {/* Export as Plain Text */} {/* Copy to Clipboard */}
{/* Export Info */}

Export Information

  • Markdown: Original markdown with all formatting (.md file)
  • PDF: Professional PDF document with formatted content - perfect for sharing and printing!
  • Full HTML: Complete standalone HTML page with GitHub Dark theme, syntax highlighting (Highlight.js), and working copy buttons - ready to use!
  • HTML Content: Body content only, ready to paste into web pages
  • Plain Text: Markdown-ready text file, keeps all syntax (.txt file)
  • Copy: Copy markdown to clipboard for pasting
)} {/* Usage Tips */}
setUsageTipsExpanded(!usageTipsExpanded)} className="px-4 py-3 cursor-pointer hover:bg-blue-100 dark:hover:bg-blue-900/30 transition-colors flex items-center justify-between" >

💡 Usage Tips

{usageTipsExpanded ? : }
{usageTipsExpanded && (
{/* Input Methods */}

📝 Input Methods:

  • Create New: Start with empty editor or load sample markdown
  • URL: Fetch markdown from GitHub, Gist, or any public URL
  • Paste: Paste markdown, HTML (auto-converts), or plain text
  • Open: Load .md, .txt, or .html files from your computer
{/* Editing Features */}

✏️ Editing Features

  • Live Preview: See your markdown rendered in real-time
  • Syntax Highlighting: Code blocks with automatic language detection
  • View Modes: Switch between Split, Editor Only, Preview Only, or Fullscreen
  • Toolbar: Quick formatting buttons for headers, bold, italic, links, code, and more
  • Statistics: Track word count, character count, lines, and reading time
  • GitHub Flavored Markdown: Full GFM support including tables and task lists
{/* Markdown Syntax */}

📖 Markdown Syntax

  • Headers: # H1, ## H2, ### H3, etc.
  • Bold: **bold text** or __bold text__
  • Italic: *italic text* or _italic text_
  • Underline: __underlined text__ (custom syntax)
  • Code: `inline code` or ```language for code blocks
  • Links: [text](url)
  • Images: ![alt text](url)
  • Lists: - or * for unordered, 1. for ordered
  • Tables: {'|'} Header {'|'} Header {'|'} with {'|'} --- {'|'} --- {'|'} separator
  • Blockquotes: {'>'} quoted text
{/* Export Options */}

📤 Export Options

  • Markdown (.md): Standard markdown format
  • Full HTML: Standalone page with styling and working copy buttons
  • HTML Content: Just the body content for embedding
  • Plain Text (.txt): Markdown-ready text file
  • Copy: Copy to clipboard for quick sharing
{/* Data Privacy */}

💾 Data Privacy

  • 100% Client-Side: All processing happens in your browser
  • No Server Upload: Your markdown never leaves your device
  • No Tracking: We don't store or track your content
  • Privacy-First: Safe for confidential documents
)}
{/* Related Tools */} ); }; // Input Method Change Confirmation Modal Component const InputChangeConfirmationModal = ({ markdownText, stats, currentMethod, newMethod, onConfirm, onCancel }) => { const getMethodName = (method) => { switch (method) { case 'create': return 'Create New'; case 'create_empty': return 'Start Empty'; case 'create_sample': return 'Load Sample'; case 'url': return 'URL Import'; case 'paste': return 'Paste Data'; case 'open': return 'File Upload'; default: return method; } }; return (
{/* Header */}

Change Input Method

This will clear all current markdown

{/* Content */}

{(newMethod === 'create_empty' || newMethod === 'create_sample') ? ( <>Using {getMethodName(newMethod)} will clear all current markdown. ) : ( <>Switching from {getMethodName(currentMethod)} to {getMethodName(newMethod)} will clear all current markdown. )}

This will permanently delete:

  • • {stats.words} words of markdown content
  • • {stats.characters} characters
  • • {stats.lines} lines

Tip: Consider downloading your current markdown before switching methods to avoid losing your work.

{/* Footer */}
); }; export default MarkdownEditor;