2268 lines
84 KiB
JavaScript
Executable File
2268 lines
84 KiB
JavaScript
Executable File
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 `
|
||
<div class="code-block-wrapper">
|
||
<div class="code-block-header">
|
||
<span class="code-block-language">${displayLang}</span>
|
||
<button
|
||
class="code-block-copy"
|
||
data-code-id="${blockId}"
|
||
title="Copy code"
|
||
>
|
||
Copy
|
||
</button>
|
||
</div>
|
||
<pre><code id="${blockId}" class="hljs language-${displayLang}">${highlightedCode}</code></pre>
|
||
</div>
|
||
`;
|
||
};
|
||
|
||
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 <u>text</u> before parsing (custom underline syntax)
|
||
// But preserve __ at start of line (which is for bold in some contexts)
|
||
let processed = markdown.replace(/(?<!_)__(?!_)(.+?)__(?!_)/g, '<u>$1</u>');
|
||
|
||
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 '<p>Error parsing markdown</p>';
|
||
}
|
||
};
|
||
|
||
// 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
|
||
<?php
|
||
header('Access-Control-Allow-Origin: *');
|
||
header('Access-Control-Allow-Methods: POST, GET, OPTIONS');
|
||
echo "Hello, World!";
|
||
?>
|
||
\`\`\`
|
||
|
||
## 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(/<body[^>]*>([\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(/<h1[^>]*>([\s\S]*?)<\/h1>/gi, '# $1\n\n');
|
||
markdown = markdown.replace(/<h2[^>]*>([\s\S]*?)<\/h2>/gi, '## $1\n\n');
|
||
markdown = markdown.replace(/<h3[^>]*>([\s\S]*?)<\/h3>/gi, '### $1\n\n');
|
||
markdown = markdown.replace(/<h4[^>]*>([\s\S]*?)<\/h4>/gi, '#### $1\n\n');
|
||
markdown = markdown.replace(/<h5[^>]*>([\s\S]*?)<\/h5>/gi, '##### $1\n\n');
|
||
markdown = markdown.replace(/<h6[^>]*>([\s\S]*?)<\/h6>/gi, '###### $1\n\n');
|
||
|
||
// Bold and Italic
|
||
markdown = markdown.replace(/<strong[^>]*>([\s\S]*?)<\/strong>/gi, '**$1**');
|
||
markdown = markdown.replace(/<b[^>]*>([\s\S]*?)<\/b>/gi, '**$1**');
|
||
markdown = markdown.replace(/<em[^>]*>([\s\S]*?)<\/em>/gi, '*$1*');
|
||
markdown = markdown.replace(/<i[^>]*>([\s\S]*?)<\/i>/gi, '*$1*');
|
||
markdown = markdown.replace(/<u[^>]*>([\s\S]*?)<\/u>/gi, '__$1__');
|
||
|
||
// Links
|
||
markdown = markdown.replace(/<a[^>]*href="([^"]*)"[^>]*>([\s\S]*?)<\/a>/gi, '[$2]($1)');
|
||
|
||
// Images
|
||
markdown = markdown.replace(/<img[^>]*src="([^"]*)"[^>]*alt="([^"]*)"[^>]*>/gi, '');
|
||
markdown = markdown.replace(/<img[^>]*alt="([^"]*)"[^>]*src="([^"]*)"[^>]*>/gi, '');
|
||
|
||
// Inline code (must come after code blocks)
|
||
markdown = markdown.replace(/<code[^>]*>([\s\S]*?)<\/code>/gi, '`$1`');
|
||
|
||
// Tables
|
||
markdown = markdown.replace(/<table[^>]*>([\s\S]*?)<\/table>/gi, (match, content) => {
|
||
let tableMarkdown = '\n';
|
||
const rows = content.match(/<tr[^>]*>([\s\S]*?)<\/tr>/gi) || [];
|
||
|
||
rows.forEach((row, index) => {
|
||
const cells = row.match(/<t[hd][^>]*>([\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(/<li[^>]*>([\s\S]*?)<\/li>/gi, (match, content) => {
|
||
content = content.trim();
|
||
return '- ' + content + '\n';
|
||
});
|
||
markdown = markdown.replace(/<ul[^>]*>([\s\S]*?)<\/ul>/gi, '$1\n');
|
||
markdown = markdown.replace(/<ol[^>]*>([\s\S]*?)<\/ol>/gi, '$1\n');
|
||
|
||
// Blockquotes
|
||
markdown = markdown.replace(/<blockquote[^>]*>([\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(/<p[^>]*>([\s\S]*?)<\/p>/gi, (match, content) => {
|
||
content = content.trim();
|
||
return content + '\n\n';
|
||
});
|
||
markdown = markdown.replace(/<br\s*\/?>/gi, '\n');
|
||
markdown = markdown.replace(/<hr\s*\/?>/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 = `<!DOCTYPE html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>Markdown Document</title>
|
||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/github-markdown-css/5.2.0/github-markdown.min.css">
|
||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github-dark.min.css">
|
||
<style>
|
||
body {
|
||
max-width: 800px;
|
||
margin: 40px auto;
|
||
padding: 20px;
|
||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
|
||
background: #0d1117;
|
||
color: #c9d1d9;
|
||
}
|
||
.markdown-body {
|
||
box-sizing: border-box;
|
||
background: #0d1117;
|
||
color: #c9d1d9;
|
||
}
|
||
/* Code block styling with copy button */
|
||
.markdown-body pre {
|
||
position: relative;
|
||
background: #161b22;
|
||
border: 1px solid #30363d;
|
||
border-radius: 6px;
|
||
padding: 16px;
|
||
overflow: auto;
|
||
}
|
||
.markdown-body pre code {
|
||
background: transparent;
|
||
padding: 0;
|
||
border: none;
|
||
}
|
||
.code-header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
padding: 8px 12px;
|
||
background: #161b22;
|
||
border: 1px solid #30363d;
|
||
border-bottom: none;
|
||
border-radius: 6px 6px 0 0;
|
||
margin-bottom: -1px;
|
||
}
|
||
.code-language {
|
||
font-size: 12px;
|
||
color: #8b949e;
|
||
font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace;
|
||
}
|
||
.copy-button {
|
||
background: #21262d;
|
||
border: 1px solid #30363d;
|
||
color: #c9d1d9;
|
||
padding: 4px 12px;
|
||
border-radius: 6px;
|
||
font-size: 12px;
|
||
cursor: pointer;
|
||
transition: all 0.2s;
|
||
}
|
||
.copy-button:hover {
|
||
background: #30363d;
|
||
border-color: #8b949e;
|
||
}
|
||
.copy-button:active {
|
||
background: #161b22;
|
||
}
|
||
.copy-button.copied {
|
||
color: #3fb950;
|
||
border-color: #3fb950;
|
||
}
|
||
/* Hide inline copy buttons that appear after language name */
|
||
.markdown-body pre > code::after,
|
||
.markdown-body pre::after,
|
||
.markdown-body code + button,
|
||
.code-block-copy {
|
||
display: none !important;
|
||
}
|
||
</style>
|
||
</head>
|
||
<body class="markdown-body">
|
||
${html}
|
||
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"></script>
|
||
<script>
|
||
// Apply syntax highlighting
|
||
document.addEventListener('DOMContentLoaded', function() {
|
||
// Remove old code-block-header and code-block-wrapper elements
|
||
document.querySelectorAll('.code-block-header').forEach(el => el.remove());
|
||
document.querySelectorAll('.code-block-wrapper').forEach(wrapper => {
|
||
// Move children out of wrapper before removing it
|
||
while (wrapper.firstChild) {
|
||
wrapper.parentNode.insertBefore(wrapper.firstChild, wrapper);
|
||
}
|
||
wrapper.remove();
|
||
});
|
||
|
||
// Remove any inline copy buttons or text that might appear
|
||
document.querySelectorAll('.code-block-copy, button[data-code-id]').forEach(el => el.remove());
|
||
|
||
// Highlight all code blocks
|
||
document.querySelectorAll('pre code').forEach((block) => {
|
||
hljs.highlightElement(block);
|
||
|
||
// Add copy button
|
||
const pre = block.parentElement;
|
||
const language = block.className.match(/language-(\\w+)/)?.[1] || 'text';
|
||
|
||
// Remove any text nodes that might contain "Copy" after the language
|
||
const parent = pre.parentNode;
|
||
Array.from(parent.childNodes).forEach(node => {
|
||
if (node.nodeType === Node.TEXT_NODE && node.textContent.includes('Copy')) {
|
||
node.remove();
|
||
}
|
||
});
|
||
|
||
// Create header with language and copy button
|
||
const header = document.createElement('div');
|
||
header.className = 'code-header';
|
||
|
||
const langLabel = document.createElement('span');
|
||
langLabel.className = 'code-language';
|
||
langLabel.textContent = language;
|
||
|
||
const copyBtn = document.createElement('button');
|
||
copyBtn.className = 'copy-button';
|
||
copyBtn.textContent = 'Copy';
|
||
copyBtn.onclick = function() {
|
||
navigator.clipboard.writeText(block.textContent).then(() => {
|
||
copyBtn.textContent = 'Copied!';
|
||
copyBtn.classList.add('copied');
|
||
setTimeout(() => {
|
||
copyBtn.textContent = 'Copy';
|
||
copyBtn.classList.remove('copied');
|
||
}, 2000);
|
||
});
|
||
};
|
||
|
||
header.appendChild(langLabel);
|
||
header.appendChild(copyBtn);
|
||
|
||
// Insert header before pre
|
||
pre.parentNode.insertBefore(header, pre);
|
||
|
||
// Adjust pre border radius
|
||
pre.style.borderRadius = '0 0 6px 6px';
|
||
});
|
||
});
|
||
</script>
|
||
</body>
|
||
</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 (
|
||
<>
|
||
<SEO
|
||
title="Free Markdown Editor with Live Preview & PDF Export"
|
||
description="✓ Live preview ✓ GitHub Flavored Markdown ✓ Export to PDF/HTML ✓ Syntax highlighting ✓ 100% free. Start writing now - no signup required!"
|
||
keywords="markdown editor, markdown preview, markdown to html, markdown to pdf, markdown converter, online markdown, github markdown, gfm, markdown syntax, code editor, documentation tool, readme editor"
|
||
path="/markdown-editor"
|
||
toolId="markdown-editor"
|
||
/>
|
||
<ToolLayout
|
||
title="Markdown Editor"
|
||
description="Write and preview markdown with live rendering, syntax highlighting, and export options"
|
||
icon={FileText}
|
||
>
|
||
{/* Input Section - Always visible */}
|
||
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 overflow-hidden mb-6">
|
||
<div className="p-4 border-b border-gray-200 dark:border-gray-700">
|
||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">Get Started</h3>
|
||
</div>
|
||
|
||
{/* Tab Navigation */}
|
||
<div className="border-b border-gray-200 dark:border-gray-700">
|
||
<div className="flex">
|
||
<button
|
||
onClick={() => handleTabChange('create')}
|
||
className={`flex items-center gap-1 sm:gap-2 px-3 sm:px-4 py-3 text-sm font-medium transition-colors whitespace-nowrap ${
|
||
activeTab === 'create'
|
||
? 'bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-300 border-b-2 border-blue-500'
|
||
: 'text-gray-600 dark:text-gray-600 hover:text-gray-900 dark:hover:text-gray-200'
|
||
}`}
|
||
>
|
||
<Plus className="h-4 w-4 flex-shrink-0" />
|
||
Create New
|
||
</button>
|
||
<button
|
||
onClick={() => handleTabChange('url')}
|
||
className={`flex items-center gap-1 sm:gap-2 px-3 sm:px-4 py-3 text-sm font-medium transition-colors whitespace-nowrap ${
|
||
activeTab === 'url'
|
||
? 'bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-300 border-b-2 border-blue-500'
|
||
: 'text-gray-600 dark:text-gray-600 hover:text-gray-900 dark:hover:text-gray-200'
|
||
}`}
|
||
>
|
||
<Globe className="h-4 w-4 flex-shrink-0" />
|
||
URL
|
||
</button>
|
||
<button
|
||
onClick={() => handleTabChange('paste')}
|
||
className={`flex items-center gap-1 sm:gap-2 px-3 sm:px-4 py-3 text-sm font-medium transition-colors whitespace-nowrap ${
|
||
activeTab === 'paste'
|
||
? 'bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-300 border-b-2 border-blue-500'
|
||
: 'text-gray-600 dark:text-gray-600 hover:text-gray-900 dark:hover:text-gray-200'
|
||
}`}
|
||
>
|
||
<FileText className="h-4 w-4 flex-shrink-0" />
|
||
Paste
|
||
</button>
|
||
<button
|
||
onClick={() => handleTabChange('open')}
|
||
className={`flex items-center gap-1 sm:gap-2 px-3 sm:px-4 py-3 text-sm font-medium transition-colors whitespace-nowrap ${
|
||
activeTab === 'open'
|
||
? 'bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-300 border-b-2 border-blue-500'
|
||
: 'text-gray-600 dark:text-gray-600 hover:text-gray-900 dark:hover:text-gray-200'
|
||
}`}
|
||
>
|
||
<Upload className="h-4 w-4 flex-shrink-0" />
|
||
Open
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Tab Content */}
|
||
{(activeTab !== 'create' || !createNewCompleted) && (
|
||
<div className="p-3 sm:p-4">
|
||
{/* Create New Tab Content */}
|
||
{activeTab === 'create' && (
|
||
<>
|
||
{!createNewCompleted ? (
|
||
<div className="space-y-4">
|
||
<div className="text-center">
|
||
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100 mb-2">
|
||
Create New Markdown Document
|
||
</h3>
|
||
<p className="text-sm text-gray-600 dark:text-gray-600 mb-4">
|
||
Choose how you'd like to begin writing
|
||
</p>
|
||
</div>
|
||
|
||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||
<button
|
||
onClick={() => {
|
||
if (hasModifiedData()) {
|
||
setPendingTabChange('create_empty');
|
||
setShowInputChangeModal(true);
|
||
} else {
|
||
clearAllData();
|
||
setMarkdownText('');
|
||
setCreateNewCompleted(true);
|
||
}
|
||
}}
|
||
className="flex flex-col items-center p-6 border-2 border-dashed border-gray-300 dark:border-gray-600 rounded-lg hover:border-blue-500 dark:hover:border-blue-400 hover:bg-blue-50 dark:hover:bg-blue-900/20 transition-colors group"
|
||
>
|
||
<Plus className="h-8 w-8 text-gray-600 group-hover:text-blue-500 dark:group-hover:text-blue-400 mb-2" />
|
||
<span className="font-medium text-gray-900 dark:text-gray-100 group-hover:text-blue-600 dark:group-hover:text-blue-400">
|
||
Start Empty
|
||
</span>
|
||
<span className="text-xs text-gray-600 dark:text-gray-600 text-center mt-1">
|
||
Begin with a blank markdown document
|
||
</span>
|
||
</button>
|
||
|
||
<div className="flex flex-col gap-3 flex-1">
|
||
<label className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||
Or choose a template:
|
||
</label>
|
||
<select
|
||
value={selectedTemplate}
|
||
onChange={(e) => setSelectedTemplate(e.target.value)}
|
||
className="tool-input"
|
||
>
|
||
<option value="sample">Sample - Markdown Features</option>
|
||
<option value="readme">README - Project Documentation</option>
|
||
<option value="blog">Blog Post - Article Template</option>
|
||
<option value="documentation">API Documentation</option>
|
||
<option value="meeting">Meeting Notes</option>
|
||
<option value="changelog">Changelog - Version History</option>
|
||
</select>
|
||
<button
|
||
onClick={() => {
|
||
if (hasModifiedData()) {
|
||
setPendingTabChange('create_sample');
|
||
setShowInputChangeModal(true);
|
||
} else {
|
||
clearAllData();
|
||
setMarkdownText(templates[selectedTemplate]);
|
||
setCreateNewCompleted(true);
|
||
}
|
||
}}
|
||
className="flex flex-col items-center p-6 border-2 border-dashed border-gray-300 dark:border-gray-600 rounded-lg hover:border-green-500 dark:hover:border-green-400 hover:bg-green-50 dark:hover:bg-green-900/20 transition-colors group"
|
||
>
|
||
<FileText className="h-8 w-8 text-gray-600 group-hover:text-green-500 dark:group-hover:text-green-400 mb-2" />
|
||
<span className="font-medium text-gray-900 dark:text-gray-100 group-hover:text-green-600 dark:group-hover:text-green-400">
|
||
Load Template
|
||
</span>
|
||
<span className="text-xs text-gray-600 dark:text-gray-600 text-center mt-1">
|
||
Start with a pre-made template
|
||
</span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-3">
|
||
<p className="text-xs text-blue-700 dark:text-blue-300">
|
||
💡 <strong>Tip:</strong> You can always import markdown later using the URL, Paste, or Open tabs.
|
||
</p>
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<div className="p-4 bg-green-50 dark:bg-green-900/20 rounded-lg">
|
||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
|
||
<span className="text-sm text-green-700 dark:text-green-300 break-words">
|
||
✓ Document ready: {markdownText ? `${stats.words} words, ${stats.characters} characters, ${stats.lines} lines` : 'Empty document'}
|
||
</span>
|
||
<button
|
||
onClick={() => setCreateNewCompleted(false)}
|
||
className="text-sm text-blue-600 dark:text-blue-400 hover:underline whitespace-nowrap"
|
||
>
|
||
Change Option ▼
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</>
|
||
)}
|
||
|
||
{/* URL Tab Content */}
|
||
{activeTab === 'url' && (
|
||
<div className="p-4">
|
||
{urlDataSummary && (
|
||
<div className="p-4 bg-green-50 dark:bg-green-900/20 rounded-lg">
|
||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
|
||
<span className="text-sm text-green-700 dark:text-green-300 break-words">
|
||
✓ Markdown loaded: ({urlDataSummary.size.toLocaleString()} chars, {urlDataSummary.lines} lines)
|
||
</span>
|
||
<button
|
||
onClick={() => setUrlDataSummary(null)}
|
||
className="text-sm text-blue-600 dark:text-blue-400 hover:underline whitespace-nowrap"
|
||
>
|
||
Fetch New URL ▼
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{!urlDataSummary && (
|
||
<div className="space-y-3">
|
||
<div className="flex gap-2">
|
||
<div className="relative flex-1">
|
||
<input
|
||
type="url"
|
||
value={fetchUrl}
|
||
onChange={(e) => 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}
|
||
/>
|
||
</div>
|
||
<button
|
||
onClick={handleFetchFromURL}
|
||
disabled={fetching || !fetchUrl.trim()}
|
||
className="bg-blue-600 hover:bg-blue-700 disabled:bg-gray-400 text-white font-medium px-4 py-2 rounded-md transition-colors flex items-center whitespace-nowrap"
|
||
>
|
||
{fetching ? 'Fetching...' : 'Fetch Data'}
|
||
</button>
|
||
</div>
|
||
<p className="text-xs text-gray-600 dark:text-gray-600">
|
||
Enter a URL to a markdown file (GitHub raw, Gist, Pastebin, etc.)
|
||
</p>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* Paste Tab Content */}
|
||
{activeTab === 'paste' && (
|
||
pasteCollapsed ? (
|
||
<div className="p-4 bg-green-50 dark:bg-green-900/20 rounded-lg">
|
||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
|
||
<span className="text-sm text-green-700 dark:text-green-300 break-words">
|
||
✓ Markdown loaded: {pasteDataSummary.format} ({pasteDataSummary.size.toLocaleString()} chars, {pasteDataSummary.lines} lines)
|
||
</span>
|
||
<button
|
||
onClick={() => setPasteCollapsed(false)}
|
||
className="text-sm text-blue-600 dark:text-blue-400 hover:underline whitespace-nowrap"
|
||
>
|
||
Edit Input ▼
|
||
</button>
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<div className="space-y-3">
|
||
<div>
|
||
<CodeMirrorEditor
|
||
value={inputText}
|
||
onChange={setInputText}
|
||
language="markdown"
|
||
placeholder="Paste your markdown here..."
|
||
maxLines={12}
|
||
showToggle={true}
|
||
className="w-full"
|
||
/>
|
||
</div>
|
||
{error && (
|
||
<div className="p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
|
||
<p className="text-sm text-red-600 dark:text-red-400">
|
||
<strong>Error:</strong> {error}
|
||
</p>
|
||
</div>
|
||
)}
|
||
<div className="flex items-center justify-between flex-shrink-0">
|
||
<div className="text-sm text-gray-600 dark:text-gray-600">
|
||
Paste markdown text
|
||
</div>
|
||
<button
|
||
onClick={handleParseMarkdown}
|
||
disabled={!inputText.trim()}
|
||
className="bg-blue-600 hover:bg-blue-700 disabled:bg-gray-400 disabled:cursor-not-allowed text-white font-medium px-4 py-2 rounded-md transition-colors flex-shrink-0"
|
||
>
|
||
Load Markdown
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)
|
||
)}
|
||
|
||
{/* Open Tab Content */}
|
||
{activeTab === 'open' && (
|
||
fileDataSummary ? (
|
||
<div className="p-4 bg-green-50 dark:bg-green-900/20 rounded-lg">
|
||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
|
||
<span className="text-sm text-green-700 dark:text-green-300 break-words">
|
||
✓ File loaded: {fileDataSummary.format} ({fileDataSummary.size.toLocaleString()} chars, {fileDataSummary.lines} lines) - {fileDataSummary.filename}
|
||
</span>
|
||
<button
|
||
onClick={() => setFileDataSummary(null)}
|
||
className="text-sm text-blue-600 dark:text-blue-400 hover:underline whitespace-nowrap"
|
||
>
|
||
Upload New File ▼
|
||
</button>
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<div className="space-y-3">
|
||
<input
|
||
ref={fileInputRef}
|
||
type="file"
|
||
accept=".md,.markdown,.txt,.html,.htm"
|
||
onChange={handleFileUpload}
|
||
className="tool-input"
|
||
/>
|
||
<div className="bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-md p-3">
|
||
<p className="text-xs text-green-700 dark:text-green-300">
|
||
🔒 <strong>Privacy:</strong> Your data stays in your browser. We don't store or upload anything.
|
||
</p>
|
||
</div>
|
||
</div>
|
||
)
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Input Method Change Confirmation Modal */}
|
||
{showInputChangeModal && (
|
||
<InputChangeConfirmationModal
|
||
markdownText={markdownText}
|
||
stats={stats}
|
||
currentMethod={activeTab}
|
||
newMethod={pendingTabChange}
|
||
onConfirm={confirmInputChange}
|
||
onCancel={cancelInputChange}
|
||
/>
|
||
)}
|
||
|
||
{/* Editor Section */}
|
||
{(activeTab !== 'create' || createNewCompleted) && (
|
||
<div className={`bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden min-w-0 w-full max-w-full ${isFullscreen ? 'fixed inset-0 z-[9999] flex flex-col !mt-0' : 'mb-6'}`}>
|
||
{/* Editor Header - Sticky */}
|
||
<div className="px-4 py-3 border-b border-gray-200 dark:border-gray-700 sticky top-0 bg-white dark:bg-gray-800 z-10">
|
||
<div className="flex items-center justify-between">
|
||
<div className="flex items-center gap-3">
|
||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white flex items-center gap-2">
|
||
<Edit3 className="h-5 w-5 text-blue-600 dark:text-blue-400" />
|
||
Markdown Editor
|
||
</h3>
|
||
|
||
{/* Statistics */}
|
||
<div className="hidden sm:flex items-center gap-3 text-xs text-gray-600 dark:text-gray-600">
|
||
<span>{stats.words} words</span>
|
||
<span>•</span>
|
||
<span>{stats.characters} chars</span>
|
||
<span>•</span>
|
||
<span>{stats.lines} lines</span>
|
||
<span>•</span>
|
||
<span>{stats.readingTime} min read</span>
|
||
</div>
|
||
</div>
|
||
|
||
{/* View Mode Controls */}
|
||
<div className="flex items-center gap-2">
|
||
<div className="flex border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
|
||
<button
|
||
onClick={() => setViewMode('editor')}
|
||
className={`flex items-center gap-2 px-3 py-2 text-sm font-medium transition-colors ${
|
||
viewMode === 'editor'
|
||
? 'bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-300'
|
||
: 'text-gray-600 dark:text-gray-600 hover:text-gray-900 dark:hover:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700'
|
||
}`}
|
||
title="Editor Only"
|
||
>
|
||
<Type className="h-4 w-4" />
|
||
<span className="hidden sm:inline">Editor</span>
|
||
</button>
|
||
{/* Hide split button on mobile (< lg) */}
|
||
<button
|
||
onClick={() => setViewMode('split')}
|
||
className={`hidden lg:flex items-center gap-2 px-3 py-2 text-sm font-medium transition-colors border-l border-gray-200 dark:border-gray-700 ${
|
||
viewMode === 'split'
|
||
? 'bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-300'
|
||
: 'text-gray-600 dark:text-gray-600 hover:text-gray-900 dark:hover:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700'
|
||
}`}
|
||
title="Split View"
|
||
>
|
||
<Columns className="h-4 w-4" />
|
||
<span className="hidden sm:inline">Split</span>
|
||
</button>
|
||
<button
|
||
onClick={() => setViewMode('preview')}
|
||
className={`flex items-center gap-2 px-3 py-2 text-sm font-medium transition-colors border-l border-gray-200 dark:border-gray-700 ${
|
||
viewMode === 'preview'
|
||
? 'bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-300'
|
||
: 'text-gray-600 dark:text-gray-600 hover:text-gray-900 dark:hover:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700'
|
||
}`}
|
||
title="Preview Only"
|
||
>
|
||
<Eye className="h-4 w-4" />
|
||
<span className="hidden sm:inline">Preview</span>
|
||
</button>
|
||
</div>
|
||
|
||
<button
|
||
onClick={() => setIsFullscreen(!isFullscreen)}
|
||
className="p-2 text-gray-600 dark:text-gray-600 hover:text-gray-900 dark:hover:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors"
|
||
title={isFullscreen ? 'Exit Fullscreen' : 'Fullscreen'}
|
||
>
|
||
{isFullscreen ? <Minimize2 className="h-4 w-4" /> : <Maximize2 className="h-4 w-4" />}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Markdown Toolbar */}
|
||
{(viewMode === 'editor' || viewMode === 'split') && (
|
||
<div className="px-4 py-2 border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900/50 min-w-0 w-full">
|
||
<div className="flex items-center gap-1 overflow-x-auto relative" style={{ WebkitOverflowScrolling: 'touch' }}>
|
||
{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 (
|
||
<React.Fragment key={idx}>
|
||
{/* Group Separator */}
|
||
{showSeparator && (
|
||
<div className="h-6 w-px bg-gray-300 dark:bg-gray-600 mx-1" />
|
||
)}
|
||
|
||
<div className="relative group">
|
||
<button
|
||
onClick={btn.action}
|
||
className="p-2 text-gray-600 dark:text-gray-600 hover:text-gray-900 dark:hover:text-gray-200 hover:bg-gray-200 dark:hover:bg-gray-700 rounded transition-colors"
|
||
aria-label={btn.label}
|
||
>
|
||
<Icon className="h-4 w-4" />
|
||
</button>
|
||
{/* Tooltip */}
|
||
<div className="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 px-2 py-1 bg-gray-900 dark:bg-gray-700 text-white text-xs rounded whitespace-nowrap opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all duration-200 pointer-events-none z-[60]">
|
||
{btn.label}
|
||
</div>
|
||
|
||
{/* Heading Dropdown */}
|
||
{btn.isDropdown && showHeadingDropdown && (
|
||
<div className="absolute top-full left-0 mt-1 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-lg py-1 z-50 min-w-[120px]">
|
||
{[1, 2, 3, 4, 5, 6].map(level => (
|
||
<button
|
||
key={level}
|
||
onClick={() => insertHeading(level)}
|
||
className="w-full px-3 py-2 text-left hover:bg-gray-100 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300 text-sm"
|
||
>
|
||
<span className="font-semibold">H{level}</span> - {'#'.repeat(level)} Heading {level}
|
||
</button>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</React.Fragment>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Editor Content */}
|
||
<div className={`${viewMode === 'split' ? 'grid grid-cols-1 lg:grid-cols-2' : ''} overflow-hidden min-w-0 w-full`}>
|
||
{/* Markdown Editor */}
|
||
{(viewMode === 'editor' || viewMode === 'split') && (
|
||
<div className={`${viewMode === 'split' ? 'border-r border-gray-200 dark:border-gray-700' : ''} ${isFullscreen ? 'h-[calc(100vh-120px)]' : 'h-[600px]'} w-full overflow-hidden min-w-0`}>
|
||
<div className="h-full">
|
||
<CodeMirrorEditor
|
||
value={markdownText}
|
||
onChange={setMarkdownText}
|
||
language="markdown"
|
||
placeholder="# Start writing your markdown here...
|
||
|
||
You can use:
|
||
- **Bold** and *italic* text
|
||
- Lists and checkboxes
|
||
- Code blocks with syntax highlighting
|
||
- Tables, links, and images
|
||
- And much more!"
|
||
showToggle={false}
|
||
maxLines={999}
|
||
height={isFullscreen ? 'calc(100vh - 120px)' : '600px'}
|
||
/>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Preview */}
|
||
{(viewMode === 'preview' || viewMode === 'split') && (
|
||
<div className={`flex flex-col bg-gray-50 dark:bg-gray-900 ${isFullscreen ? 'h-[calc(100vh-120px)]' : 'h-[600px]'} w-full overflow-hidden min-w-0`}>
|
||
<div className="flex-1 overflow-auto p-4 min-w-0 w-full">
|
||
{markdownText ? (
|
||
<div
|
||
className="markdown-preview prose prose-slate dark:prose-invert max-w-full"
|
||
dangerouslySetInnerHTML={{ __html: parseMarkdown(markdownText) }}
|
||
/>
|
||
) : (
|
||
<div className="flex items-center justify-center h-full text-gray-600 dark:text-gray-600">
|
||
<div className="text-center">
|
||
<EyeOff className="h-12 w-12 mx-auto mb-2 opacity-50" />
|
||
<p className="text-sm">Preview will appear here</p>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Export Section */}
|
||
{markdownText && (
|
||
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 overflow-hidden min-w-0 w-full max-w-full">
|
||
<div className="p-4 border-b border-gray-200 dark:border-gray-700">
|
||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white flex items-center gap-2">
|
||
<Download className="h-5 w-5 text-blue-600 dark:text-blue-400" />
|
||
Export Options
|
||
</h3>
|
||
<p className="text-sm text-gray-600 dark:text-gray-600 mt-1">
|
||
Download your markdown in different formats
|
||
</p>
|
||
</div>
|
||
|
||
<div className="p-4 sm:p-6">
|
||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6 gap-4">
|
||
{/* Export as Markdown */}
|
||
<button
|
||
onClick={handleExportMarkdown}
|
||
className="flex flex-col items-center justify-center p-4 md:p-6 border-2 border-gray-200 dark:border-gray-700 rounded-lg hover:border-blue-500 dark:hover:border-blue-400 hover:bg-blue-50 dark:hover:bg-blue-900/20 transition-all group relative"
|
||
>
|
||
<FileText className="h-8 w-8 absolute left-4 top-5 md:static md:left-0 md:top-0 text-gray-600 dark:text-gray-600 group-hover:text-blue-600 dark:group-hover:text-blue-400 mb-3" />
|
||
<span className="font-medium text-gray-900 dark:text-white mb-1">Markdown</span>
|
||
<span className="text-xs text-gray-600 dark:text-gray-600">.md file</span>
|
||
</button>
|
||
|
||
{/* Export as PDF */}
|
||
<button
|
||
onClick={handleExportPDF}
|
||
className="flex flex-col items-center justify-center p-4 md:p-6 border-2 border-gray-200 dark:border-gray-700 rounded-lg hover:border-red-500 dark:hover:border-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 transition-all group relative"
|
||
>
|
||
<FileDown className="h-8 w-8 absolute left-4 top-5 md:static md:left-0 md:top-0 text-gray-600 dark:text-gray-600 group-hover:text-red-600 dark:group-hover:text-red-400 mb-3" />
|
||
<span className="font-medium text-gray-900 dark:text-white mb-1">PDF</span>
|
||
<span className="text-xs text-gray-600 dark:text-gray-600">.pdf file</span>
|
||
</button>
|
||
|
||
{/* Export as Full HTML */}
|
||
<button
|
||
onClick={handleExportHTML}
|
||
className="flex flex-col items-center justify-center p-4 md:p-6 border-2 border-gray-200 dark:border-gray-700 rounded-lg hover:border-green-500 dark:hover:border-green-400 hover:bg-green-50 dark:hover:bg-green-900/20 transition-all group relative"
|
||
>
|
||
<Globe className="h-8 w-8 absolute left-4 top-5 md:static md:left-0 md:top-0 text-gray-600 dark:text-gray-600 group-hover:text-green-600 dark:group-hover:text-green-400 mb-3" />
|
||
<span className="font-medium text-gray-900 dark:text-white mb-1">Full HTML</span>
|
||
<span className="text-xs text-gray-600 dark:text-gray-600">.html page</span>
|
||
</button>
|
||
|
||
{/* Export as HTML Content Only */}
|
||
<button
|
||
onClick={handleExportHTMLContent}
|
||
className="flex flex-col items-center justify-center p-4 md:p-6 border-2 border-gray-200 dark:border-gray-700 rounded-lg hover:border-teal-500 dark:hover:border-teal-400 hover:bg-teal-50 dark:hover:bg-teal-900/20 transition-all group relative"
|
||
>
|
||
<Code className="h-8 w-8 absolute left-4 top-5 md:static md:left-0 md:top-0 text-gray-600 dark:text-gray-600 group-hover:text-teal-600 dark:group-hover:text-teal-400 mb-3" />
|
||
<span className="font-medium text-gray-900 dark:text-white mb-1">HTML Content</span>
|
||
<span className="text-xs text-gray-600 dark:text-gray-600">Body only</span>
|
||
</button>
|
||
|
||
{/* Export as Plain Text */}
|
||
<button
|
||
onClick={handleExportPlainText}
|
||
className="flex flex-col items-center justify-center p-4 md:p-6 border-2 border-gray-200 dark:border-gray-700 rounded-lg hover:border-purple-500 dark:hover:border-purple-400 hover:bg-purple-50 dark:hover:bg-purple-900/20 transition-all group relative"
|
||
>
|
||
<Type className="h-8 w-8 absolute left-4 top-5 md:static md:left-0 md:top-0 text-gray-600 dark:text-gray-600 group-hover:text-purple-600 dark:group-hover:text-purple-400 mb-3" />
|
||
<span className="font-medium text-gray-900 dark:text-white mb-1">Plain Text</span>
|
||
<span className="text-xs text-gray-600 dark:text-gray-600">.txt file</span>
|
||
</button>
|
||
|
||
{/* Copy to Clipboard */}
|
||
<button
|
||
onClick={handleCopyToClipboard}
|
||
className="flex flex-col items-center justify-center p-4 md:p-6 border-2 border-gray-200 dark:border-gray-700 rounded-lg hover:border-orange-500 dark:hover:border-orange-400 hover:bg-orange-50 dark:hover:bg-orange-900/20 transition-all group relative"
|
||
>
|
||
<Download className="h-8 w-8 absolute left-4 top-5 md:static md:left-0 md:top-0 text-gray-600 dark:text-gray-600 group-hover:text-orange-600 dark:group-hover:text-orange-400 mb-3" />
|
||
<span className="font-medium text-gray-900 dark:text-white mb-1">Copy</span>
|
||
<span className="text-xs text-gray-600 dark:text-gray-600">To clipboard</span>
|
||
</button>
|
||
</div>
|
||
|
||
{/* Export Info */}
|
||
<div className="mt-6 p-4 bg-amber-50 dark:bg-amber-900/20 rounded-lg border border-amber-200 dark:border-amber-800">
|
||
<div className="flex items-start gap-3">
|
||
<AlertTriangle className="h-5 w-5 text-amber-600 dark:text-amber-400 flex-shrink-0 mt-0.5" />
|
||
<div className="text-sm text-amber-800 dark:text-amber-200">
|
||
<p className="font-medium mb-1">Export Information</p>
|
||
<ul className="list-disc list-inside space-y-1 text-amber-700 dark:text-amber-300">
|
||
<li><strong>Markdown:</strong> Original markdown with all formatting (.md file)</li>
|
||
<li><strong>PDF:</strong> Professional PDF document with formatted content - perfect for sharing and printing!</li>
|
||
<li><strong>Full HTML:</strong> Complete standalone HTML page with GitHub Dark theme, syntax highlighting (Highlight.js), and working copy buttons - ready to use!</li>
|
||
<li><strong>HTML Content:</strong> Body content only, ready to paste into web pages</li>
|
||
<li><strong>Plain Text:</strong> Markdown-ready text file, keeps all syntax (.txt file)</li>
|
||
<li><strong>Copy:</strong> Copy markdown to clipboard for pasting</li>
|
||
</ul>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Usage Tips */}
|
||
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-md overflow-hidden mt-6">
|
||
<div
|
||
onClick={() => 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"
|
||
>
|
||
<h4 className="text-blue-800 dark:text-blue-200 font-medium flex items-center gap-2">
|
||
💡 Usage Tips
|
||
</h4>
|
||
{usageTipsExpanded ? <ChevronUp className="h-4 w-4 text-blue-600 dark:text-blue-400" /> : <ChevronDown className="h-4 w-4 text-blue-600 dark:text-blue-400" />}
|
||
</div>
|
||
|
||
{usageTipsExpanded && (
|
||
<div className="px-4 pb-4 text-blue-700 dark:text-blue-300 text-sm space-y-2">
|
||
{/* Input Methods */}
|
||
<div>
|
||
<p className="font-medium mb-1">📝 Input Methods:</p>
|
||
<ul className="list-disc list-inside space-y-1 ml-2">
|
||
<li><strong>Create New:</strong> Start with empty editor or load sample markdown</li>
|
||
<li><strong>URL:</strong> Fetch markdown from GitHub, Gist, or any public URL</li>
|
||
<li><strong>Paste:</strong> Paste markdown, HTML (auto-converts), or plain text</li>
|
||
<li><strong>Open:</strong> Load .md, .txt, or .html files from your computer</li>
|
||
</ul>
|
||
</div>
|
||
|
||
{/* Editing Features */}
|
||
<div>
|
||
<h4 className="font-semibold text-blue-700 dark:text-blue-300 mb-2">✏️ Editing Features</h4>
|
||
<ul className="list-disc list-inside space-y-1 ml-2">
|
||
<li><strong>Live Preview:</strong> See your markdown rendered in real-time</li>
|
||
<li><strong>Syntax Highlighting:</strong> Code blocks with automatic language detection</li>
|
||
<li><strong>View Modes:</strong> Switch between Split, Editor Only, Preview Only, or Fullscreen</li>
|
||
<li><strong>Toolbar:</strong> Quick formatting buttons for headers, bold, italic, links, code, and more</li>
|
||
<li><strong>Statistics:</strong> Track word count, character count, lines, and reading time</li>
|
||
<li><strong>GitHub Flavored Markdown:</strong> Full GFM support including tables and task lists</li>
|
||
</ul>
|
||
</div>
|
||
|
||
{/* Markdown Syntax */}
|
||
<div>
|
||
<h4 className="font-semibold text-blue-700 dark:text-blue-300 mb-2">📖 Markdown Syntax</h4>
|
||
<ul className="list-disc list-inside space-y-1 ml-2">
|
||
<li><strong>Headers:</strong> # H1, ## H2, ### H3, etc.</li>
|
||
<li><strong>Bold:</strong> **bold text** or __bold text__</li>
|
||
<li><strong>Italic:</strong> *italic text* or _italic text_</li>
|
||
<li><strong>Underline:</strong> __underlined text__ (custom syntax)</li>
|
||
<li><strong>Code:</strong> `inline code` or ```language for code blocks</li>
|
||
<li><strong>Links:</strong> [text](url)</li>
|
||
<li><strong>Images:</strong> </li>
|
||
<li><strong>Lists:</strong> - or * for unordered, 1. for ordered</li>
|
||
<li><strong>Tables:</strong> {'|'} Header {'|'} Header {'|'} with {'|'} --- {'|'} --- {'|'} separator</li>
|
||
<li><strong>Blockquotes:</strong> {'>'} quoted text</li>
|
||
</ul>
|
||
</div>
|
||
|
||
{/* Export Options */}
|
||
<div>
|
||
<h4 className="font-semibold text-blue-700 dark:text-blue-300 mb-2">📤 Export Options</h4>
|
||
<ul className="list-disc list-inside space-y-1 ml-2">
|
||
<li><strong>Markdown (.md):</strong> Standard markdown format</li>
|
||
<li><strong>Full HTML:</strong> Standalone page with styling and working copy buttons</li>
|
||
<li><strong>HTML Content:</strong> Just the body content for embedding</li>
|
||
<li><strong>Plain Text (.txt):</strong> Markdown-ready text file</li>
|
||
<li><strong>Copy:</strong> Copy to clipboard for quick sharing</li>
|
||
</ul>
|
||
</div>
|
||
|
||
{/* Data Privacy */}
|
||
<div>
|
||
<h4 className="font-semibold text-blue-700 dark:text-blue-300 mb-2">💾 Data Privacy</h4>
|
||
<ul className="list-disc list-inside space-y-1 ml-2">
|
||
<li><strong>100% Client-Side:</strong> All processing happens in your browser</li>
|
||
<li><strong>No Server Upload:</strong> Your markdown never leaves your device</li>
|
||
<li><strong>No Tracking:</strong> We don't store or track your content</li>
|
||
<li><strong>Privacy-First:</strong> Safe for confidential documents</li>
|
||
</ul>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Related Tools */}
|
||
<RelatedTools toolId="markdown-editor" />
|
||
</ToolLayout>
|
||
</>
|
||
);
|
||
};
|
||
|
||
// 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 (
|
||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-md w-full overflow-hidden">
|
||
{/* Header */}
|
||
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700 bg-amber-50 dark:bg-amber-900/20">
|
||
<div className="flex items-center gap-3">
|
||
<div className="flex-shrink-0">
|
||
<AlertTriangle className="h-6 w-6 text-amber-600 dark:text-amber-400" />
|
||
</div>
|
||
<div>
|
||
<h3 className="text-lg font-semibold text-amber-900 dark:text-amber-100">
|
||
Change Input Method
|
||
</h3>
|
||
<p className="text-sm text-amber-700 dark:text-amber-300">
|
||
This will clear all current markdown
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Content */}
|
||
<div className="px-6 py-4">
|
||
<p className="text-gray-700 dark:text-gray-300 mb-4">
|
||
{(newMethod === 'create_empty' || newMethod === 'create_sample') ? (
|
||
<>Using <strong>{getMethodName(newMethod)}</strong> will clear all current markdown.</>
|
||
) : (
|
||
<>Switching from <strong>{getMethodName(currentMethod)}</strong> to <strong>{getMethodName(newMethod)}</strong> will clear all current markdown.</>
|
||
)}
|
||
</p>
|
||
|
||
<div className="bg-gray-50 dark:bg-gray-700 rounded-lg p-4 mb-4">
|
||
<h4 className="text-sm font-medium text-gray-900 dark:text-gray-100 mb-2">
|
||
This will permanently delete:
|
||
</h4>
|
||
<ul className="text-sm text-gray-600 dark:text-gray-600 space-y-1">
|
||
<li>• {stats.words} words of markdown content</li>
|
||
<li>• {stats.characters} characters</li>
|
||
<li>• {stats.lines} lines</li>
|
||
</ul>
|
||
</div>
|
||
|
||
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-3">
|
||
<div className="flex items-start gap-2">
|
||
<AlertTriangle className="h-4 w-4 text-blue-600 dark:text-blue-400 flex-shrink-0 mt-0.5" />
|
||
<p className="text-xs text-blue-700 dark:text-blue-300">
|
||
<strong>Tip:</strong> Consider downloading your current markdown before switching methods to avoid losing your work.
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Footer */}
|
||
<div className="px-6 py-4 border-t border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-700">
|
||
<div className="flex justify-end gap-3">
|
||
<button
|
||
onClick={onCancel}
|
||
className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-600 border border-gray-300 dark:border-gray-500 rounded-md hover:bg-gray-50 dark:hover:bg-gray-500 transition-colors"
|
||
>
|
||
Cancel
|
||
</button>
|
||
<button
|
||
onClick={onConfirm}
|
||
className="px-4 py-2 text-sm font-medium text-white bg-amber-600 hover:bg-amber-700 rounded-md transition-colors flex items-center gap-2"
|
||
>
|
||
<AlertTriangle className="h-4 w-4" />
|
||
Switch & Clear Data
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
export default MarkdownEditor;
|