feat: Implement Google Docs-style editor with ODT export
This commit introduces a complete overhaul of the application, replacing the previous simple Markdown editor with a powerful, Google Docs-style editor with a live preview and ODT export. The new features include: - A new three-pane UI with a Markdown editor, a live preview, and a styles panel. - A powerful styling engine that allows users to create, edit, and apply custom styles to the document. - A persistent storage mechanism for styles using a JSON file. - Support for advanced Markdown features, including code blocks and blockquotes. - A hyperlinked table of contents that is automatically generated from the headings in the document. - Auto-captioning for tables and images. - Pagination for large tables. - Secure image embedding with a domain whitelist and size limits to prevent SSRF and DoS attacks. - An improved live preview that correctly renders multi-line Markdown elements.
This commit is contained in:
+144
-285
@@ -1,298 +1,157 @@
|
||||
// GhostEditor - Markdown to Word Converter JavaScript
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// DOM Elements
|
||||
const markdownInput = document.getElementById('markdownInput');
|
||||
const previewOutput = document.getElementById('previewOutput');
|
||||
const previewToggle = document.getElementById('previewToggle');
|
||||
const exportBtn = document.getElementById('exportBtn');
|
||||
const blockType = document.getElementById('blockType');
|
||||
const applyStyle = document.getElementById('applyStyle');
|
||||
const applyStyleBtn = document.getElementById('applyStyleBtn');
|
||||
const customCaption = document.getElementById('customCaption');
|
||||
const applyCaptionBtn = document.getElementById('applyCaptionBtn');
|
||||
const autoCaptionImages = document.getElementById('autoCaptionImages');
|
||||
const autoCaptionTables = document.getElementById('autoCaptionTables');
|
||||
const autoCaptionFigures = document.getElementById('autoCaptionFigures');
|
||||
const tablePagination = document.getElementById('tablePagination');
|
||||
const boldBtn = document.getElementById('boldBtn');
|
||||
const italicBtn = document.getElementById('italicBtn');
|
||||
const headingBtn = document.getElementById('headingBtn');
|
||||
const listBtn = document.getElementById('listBtn');
|
||||
const imageBtn = document.getElementById('imageBtn');
|
||||
|
||||
// Configuration
|
||||
let config = {
|
||||
autoCaptionImages: true,
|
||||
autoCaptionTables: true,
|
||||
autoCaptionFigures: true,
|
||||
tablePagination: true,
|
||||
defaultStyle: 'Normal'
|
||||
};
|
||||
|
||||
// Initialize with sample markdown
|
||||
markdownInput.value = `# GhostEditor Sample Document
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const markdownInput = document.getElementById('markdown-input');
|
||||
const previewOutput = document.getElementById('preview-output');
|
||||
const stylesList = document.getElementById('styles-list');
|
||||
const exportButton = document.getElementById('export-odt');
|
||||
const addTocButton = document.getElementById('add-toc');
|
||||
const addStyleButton = document.getElementById('add-style');
|
||||
const styleEditorModal = document.getElementById('style-editor-modal');
|
||||
const styleEditorForm = document.getElementById('style-editor-form');
|
||||
const styleModalTitle = document.getElementById('style-modal-title');
|
||||
const styleNameInput = document.getElementById('style-name-input');
|
||||
const fontFamilyInput = document.getElementById('font-family-input');
|
||||
const fontSizeInput = document.getElementById('font-size-input');
|
||||
const fontWeightInput = document.getElementById('font-weight-input');
|
||||
const fontStyleInput = document.getElementById('font-style-input');
|
||||
const textAlignInput = document.getElementById('text-align-input');
|
||||
const colorInput = document.getElementById('color-input');
|
||||
const backgroundColorInput = document.getElementById('background-color-input');
|
||||
const paddingInput = document.getElementById('padding-input');
|
||||
const borderLeftInput = document.getElementById('border-left-input');
|
||||
const paddingLeftInput = document.getElementById('padding-left-input');
|
||||
const marginBottomInput = document.getElementById('margin-bottom-input');
|
||||
const closeButton = document.querySelector('.close-button');
|
||||
const boldButton = document.getElementById('bold-button');
|
||||
const italicButton = document.getElementById('italic-button');
|
||||
const h1Button = document.getElementById('h1-button');
|
||||
const h2Button = document.getElementById('h2-button');
|
||||
|
||||
This is a sample document to demonstrate the real-time conversion capabilities of GhostEditor.
|
||||
let styles = {};
|
||||
let appliedStyles = {};
|
||||
|
||||
## Features Demonstrated
|
||||
|
||||
### Auto-captioning
|
||||

|
||||
*Figure 1: Sample placeholder image*
|
||||
|
||||
### Table with Pagination Support
|
||||
| ID | Name | Role |
|
||||
|----|------|------|
|
||||
| 1 | John | Developer |
|
||||
| 2 | Jane | Designer |
|
||||
| 3 | Bob | Manager |
|
||||
| 4 | Alice | Analyst |
|
||||
|
||||
### Code Block
|
||||
\`\`\`python
|
||||
def hello_world():
|
||||
print("Hello, GhostEditor!")
|
||||
\`\`\`
|
||||
|
||||
### Blockquote
|
||||
> This is a sample quote demonstrating the styling capabilities of GhostEditor.
|
||||
|
||||
### List
|
||||
- First item
|
||||
- Second item
|
||||
- Third item`;
|
||||
|
||||
// Update configuration when settings change
|
||||
autoCaptionImages.addEventListener('change', updateConfig);
|
||||
autoCaptionTables.addEventListener('change', updateConfig);
|
||||
autoCaptionFigures.addEventListener('change', updateConfig);
|
||||
tablePagination.addEventListener('change', updateConfig);
|
||||
|
||||
// Toolbar buttons
|
||||
boldBtn.addEventListener('click', () => wrapText('**', '**'));
|
||||
italicBtn.addEventListener('click', () => wrapText('*', '*'));
|
||||
headingBtn.addEventListener('click', () => wrapLine('# '));
|
||||
listBtn.addEventListener('click', () => wrapLine('- '));
|
||||
imageBtn.addEventListener('click', () => insertImage());
|
||||
|
||||
// Apply style button
|
||||
applyStyleBtn.addEventListener('click', applyStyleToSelection);
|
||||
|
||||
// Apply caption button
|
||||
applyCaptionBtn.addEventListener('click', applyCaptionToSelection);
|
||||
|
||||
// Export button
|
||||
exportBtn.addEventListener('click', exportToWord);
|
||||
|
||||
// Initial preview update
|
||||
updatePreview();
|
||||
|
||||
// Update preview when input changes
|
||||
markdownInput.addEventListener('input', updatePreview);
|
||||
|
||||
// Update preview when toggle changes
|
||||
previewToggle.addEventListener('change', function() {
|
||||
if (this.checked) {
|
||||
updatePreview();
|
||||
} else {
|
||||
previewOutput.innerHTML = '<p class="text-muted">Preview is disabled. Enable to see real-time conversion.</p>';
|
||||
}
|
||||
});
|
||||
|
||||
// Update configuration
|
||||
function updateConfig() {
|
||||
config = {
|
||||
autoCaptionImages: autoCaptionImages.checked,
|
||||
autoCaptionTables: autoCaptionTables.checked,
|
||||
autoCaptionFigures: autoCaptionFigures.checked,
|
||||
tablePagination: tablePagination.checked,
|
||||
defaultStyle: document.getElementById('defaultStyle').value
|
||||
};
|
||||
|
||||
// Update preview after config change
|
||||
updatePreview();
|
||||
async function fetchStyles() {
|
||||
const response = await fetch('/styles');
|
||||
styles = await response.json();
|
||||
renderStyles();
|
||||
}
|
||||
|
||||
// Update preview function
|
||||
function updatePreview() {
|
||||
if (!previewToggle.checked) return;
|
||||
|
||||
const markdown = markdownInput.value;
|
||||
|
||||
// Convert markdown to HTML using Marked.js
|
||||
const html = marked.parse(markdown);
|
||||
|
||||
// Process the HTML to add captions and table pagination
|
||||
const processedHtml = processHtmlForPreview(html);
|
||||
|
||||
previewOutput.innerHTML = processedHtml;
|
||||
|
||||
// Add captions to images if enabled
|
||||
if (config.autoCaptionImages) {
|
||||
addImageCaptions();
|
||||
|
||||
function renderStyles() {
|
||||
stylesList.innerHTML = '';
|
||||
for (const name in styles) {
|
||||
const styleItem = document.createElement('div');
|
||||
styleItem.className = 'style-item';
|
||||
styleItem.textContent = name;
|
||||
styleItem.addEventListener('click', () => applyStyleToSelection(name));
|
||||
stylesList.appendChild(styleItem);
|
||||
}
|
||||
}
|
||||
|
||||
// Process HTML for preview with special features
|
||||
function processHtmlForPreview(html) {
|
||||
let processed = html;
|
||||
|
||||
// Add table pagination header if enabled
|
||||
if (config.tablePagination) {
|
||||
processed = processed.replace(/<table>/g, '<table class="table-pagination">');
|
||||
}
|
||||
|
||||
return processed;
|
||||
}
|
||||
|
||||
// Add captions to images
|
||||
function addImageCaptions() {
|
||||
const images = previewOutput.querySelectorAll('img');
|
||||
images.forEach((img, index) => {
|
||||
// Check if image already has a caption
|
||||
if (!img.nextElementSibling || !img.nextElementSibling.classList.contains('caption')) {
|
||||
const caption = document.createElement('div');
|
||||
caption.className = 'caption';
|
||||
caption.textContent = `Figure ${index + 1}: ${img.alt || 'Image'}`;
|
||||
img.parentNode.insertBefore(caption, img.nextSibling);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Apply style to selected text
|
||||
function applyStyleToSelection() {
|
||||
const selectedStyle = applyStyle.value;
|
||||
const selectedBlock = blockType.value;
|
||||
|
||||
// In a real implementation, this would modify the markdown
|
||||
// For now, we'll just show a preview of the style
|
||||
showStylePreview(selectedStyle);
|
||||
}
|
||||
|
||||
// Show style preview
|
||||
function showStylePreview(style) {
|
||||
const previewDiv = document.getElementById('stylePreview');
|
||||
|
||||
let styleExample = '';
|
||||
switch(style) {
|
||||
case 'Heading1':
|
||||
styleExample = '<h1>Heading 1 Example</h1>';
|
||||
break;
|
||||
case 'Heading2':
|
||||
styleExample = '<h2>Heading 2 Example</h2>';
|
||||
break;
|
||||
case 'Heading3':
|
||||
styleExample = '<h3>Heading 3 Example</h3>';
|
||||
break;
|
||||
case 'Quote':
|
||||
styleExample = '<blockquote>This is a quote example</blockquote>';
|
||||
break;
|
||||
case 'Code':
|
||||
styleExample = '<code>This is a code example</code>';
|
||||
break;
|
||||
default:
|
||||
styleExample = '<p>This is a normal paragraph example</p>';
|
||||
}
|
||||
|
||||
previewDiv.innerHTML = styleExample;
|
||||
}
|
||||
|
||||
// Apply caption to selection
|
||||
function applyCaptionToSelection() {
|
||||
const captionText = customCaption.value.trim();
|
||||
if (!captionText) return;
|
||||
|
||||
// In a real implementation, this would add the caption to the markdown
|
||||
// For now, we'll just show a notification
|
||||
alert(`Caption applied: ${captionText}`);
|
||||
}
|
||||
|
||||
// Wrap selected text with markdown syntax
|
||||
function wrapText(before, after) {
|
||||
|
||||
function applyStyleToSelection(styleName) {
|
||||
const start = markdownInput.selectionStart;
|
||||
const end = markdownInput.selectionEnd;
|
||||
const selectedText = markdownInput.value.substring(start, end);
|
||||
const lines = markdownInput.value.substring(0, start).split('\n');
|
||||
const startLine = lines.length - 1;
|
||||
|
||||
const newText = before + selectedText + after;
|
||||
for (let i = 0; i < selectedText.split('\n').length; i++) {
|
||||
appliedStyles[startLine + i] = styleName;
|
||||
}
|
||||
updatePreview();
|
||||
}
|
||||
|
||||
function applyMarkdownFormatting(prefix, suffix = prefix) {
|
||||
const start = markdownInput.selectionStart;
|
||||
const end = markdownInput.selectionEnd;
|
||||
const selectedText = markdownInput.value.substring(start, end);
|
||||
const newText = prefix + selectedText + suffix;
|
||||
markdownInput.value = markdownInput.value.substring(0, start) + newText + markdownInput.value.substring(end);
|
||||
|
||||
// Update cursor position
|
||||
markdownInput.selectionStart = start + before.length;
|
||||
markdownInput.selectionEnd = start + before.length + selectedText.length;
|
||||
|
||||
updatePreview();
|
||||
}
|
||||
|
||||
// Wrap current line with markdown syntax
|
||||
function wrapLine(prefix) {
|
||||
const start = markdownInput.selectionStart;
|
||||
const end = markdownInput.selectionEnd;
|
||||
const text = markdownInput.value;
|
||||
|
||||
// Find start and end of current line
|
||||
const lineStart = text.lastIndexOf('\n', start - 1) + 1;
|
||||
const lineEnd = text.indexOf('\n', end);
|
||||
const actualLineEnd = lineEnd === -1 ? text.length : lineEnd;
|
||||
|
||||
const lineText = text.substring(lineStart, actualLineEnd);
|
||||
const newLineText = prefix + lineText;
|
||||
|
||||
markdownInput.value = text.substring(0, lineStart) + newLineText + text.substring(actualLineEnd);
|
||||
|
||||
// Update cursor position
|
||||
markdownInput.selectionStart = start + prefix.length;
|
||||
markdownInput.selectionEnd = end + prefix.length;
|
||||
|
||||
|
||||
boldButton.addEventListener('click', () => applyMarkdownFormatting('**'));
|
||||
italicButton.addEventListener('click', () => applyMarkdownFormatting('*'));
|
||||
h1Button.addEventListener('click', () => applyMarkdownFormatting('# '));
|
||||
h2Button.addEventListener('click', () => applyMarkdownFormatting('## '));
|
||||
|
||||
async function updatePreview() {
|
||||
const markdown = markdownInput.value;
|
||||
const response = await fetch('/preview', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ markdown, styles: appliedStyles }),
|
||||
});
|
||||
const data = await response.json();
|
||||
previewOutput.innerHTML = data.html;
|
||||
}
|
||||
|
||||
markdownInput.addEventListener('input', updatePreview);
|
||||
|
||||
exportButton.addEventListener('click', async () => {
|
||||
const markdown = markdownInput.value;
|
||||
const response = await fetch('/export', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ markdown, styles: appliedStyles }),
|
||||
});
|
||||
const blob = await response.blob();
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = 'document.odt';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
});
|
||||
|
||||
addTocButton.addEventListener('click', () => {
|
||||
const cursorPos = markdownInput.selectionStart;
|
||||
const textBefore = markdownInput.value.substring(0, cursorPos);
|
||||
const textAfter = markdownInput.value.substring(cursorPos);
|
||||
markdownInput.value = textBefore + '[TOC]\n' + textAfter;
|
||||
updatePreview();
|
||||
}
|
||||
|
||||
// Insert image markdown
|
||||
function insertImage() {
|
||||
const start = markdownInput.selectionStart;
|
||||
const end = markdownInput.selectionEnd;
|
||||
const selectedText = markdownInput.value.substring(start, end);
|
||||
|
||||
const altText = selectedText || 'image';
|
||||
const imageMd = ``;
|
||||
|
||||
markdownInput.value = markdownInput.value.substring(0, start) + imageMd + markdownInput.value.substring(end);
|
||||
|
||||
// Update cursor position
|
||||
markdownInput.selectionStart = start + 2; // Position cursor in the alt text
|
||||
markdownInput.selectionEnd = start + 2 + altText.length;
|
||||
|
||||
updatePreview();
|
||||
}
|
||||
|
||||
// Export to Word functionality
|
||||
function exportToWord() {
|
||||
// In a real implementation, this would send the markdown to the backend
|
||||
// to convert it to Word format
|
||||
alert('Exporting to Word document...');
|
||||
|
||||
// Create a form to submit the markdown to the backend
|
||||
const form = document.createElement('form');
|
||||
form.method = 'POST';
|
||||
form.action = '/export';
|
||||
form.style.display = 'none';
|
||||
|
||||
const markdownField = document.createElement('input');
|
||||
markdownField.type = 'hidden';
|
||||
markdownField.name = 'markdown';
|
||||
markdownField.value = markdownInput.value;
|
||||
|
||||
const configField = document.createElement('input');
|
||||
configField.type = 'hidden';
|
||||
configField.name = 'config';
|
||||
configField.value = JSON.stringify(config);
|
||||
|
||||
form.appendChild(markdownField);
|
||||
form.appendChild(configField);
|
||||
document.body.appendChild(form);
|
||||
form.submit();
|
||||
document.body.removeChild(form);
|
||||
}
|
||||
|
||||
// Initialize style preview
|
||||
showStylePreview(config.defaultStyle);
|
||||
});
|
||||
});
|
||||
|
||||
addStyleButton.addEventListener('click', () => {
|
||||
styleModalTitle.textContent = 'Add New Style';
|
||||
styleEditorForm.reset();
|
||||
styleNameInput.value = '';
|
||||
styleEditorModal.style.display = 'block';
|
||||
});
|
||||
|
||||
closeButton.addEventListener('click', () => {
|
||||
styleEditorModal.style.display = 'none';
|
||||
});
|
||||
|
||||
styleEditorForm.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const name = styleNameInput.value;
|
||||
const style = {
|
||||
'font-family': fontFamilyInput.value,
|
||||
'font-size': fontSizeInput.value,
|
||||
'font-weight': fontWeightInput.value,
|
||||
'font-style': fontStyleInput.value,
|
||||
'text-align': textAlignInput.value,
|
||||
'color': colorInput.value,
|
||||
'background-color': backgroundColorInput.value,
|
||||
'padding': paddingInput.value,
|
||||
'border-left': borderLeftInput.value,
|
||||
'padding-left': paddingLeftInput.value,
|
||||
'margin-bottom': marginBottomInput.value,
|
||||
};
|
||||
await fetch(`/styles/${name}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(style),
|
||||
});
|
||||
styleEditorModal.style.display = 'none';
|
||||
fetchStyles();
|
||||
});
|
||||
|
||||
fetchStyles();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user