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:
google-labs-jules[bot]
2025-12-05 12:17:59 +00:00
parent 368426f0f4
commit 144bfb2ba7
6 changed files with 629 additions and 761 deletions
+144 -285
View File
@@ -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
![Sample Image](https://via.placeholder.com/150)
*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 = `![${altText}](path/to/image.png)`;
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();
});
+95 -137
View File
@@ -1,165 +1,123 @@
/* GhostEditor - Markdown to Word Converter Styles */
body {
font-family: 'Arial', sans-serif;
margin: 0;
padding: 0;
background-color: #f8f9fa;
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
color: #202124;
}
.card {
border: 1px solid #dee2e6;
border-radius: 0.5rem;
box-shadow: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075);
.main-container {
display: flex;
height: 100vh;
}
.card-header {
background-color: #e9ecef;
border-bottom: 1px solid #dee2e6;
font-weight: 600;
.editor-pane {
flex: 1;
display: flex;
flex-direction: column;
border-right: 1px solid #dadce0;
}
#markdownInput {
.toolbar {
padding: 8px 16px;
background-color: #edf2fa;
border-bottom: 1px solid #dadce0;
display: flex;
align-items: center;
}
.toolbar button {
background-color: transparent;
border: none;
cursor: pointer;
font-size: 18px;
margin-right: 16px;
padding: 4px;
}
.toolbar button:hover {
background-color: #d2e3fc;
}
.markdown-input {
flex: 1;
padding: 48px;
border: none;
font-size: 16px;
line-height: 1.6;
resize: none;
height: 500px;
font-family: 'Courier New', monospace;
background-color: #fff;
}
.preview-container {
min-height: 500px;
background-color: white;
border: 1px solid #dee2e6;
border-radius: 0.25rem;
}
#previewOutput {
height: 100%;
overflow-y: auto;
padding: 1rem;
}
/* Markdown preview styling */
#previewOutput h1 {
color: #2c3e50;
border-bottom: 2px solid #3498db;
padding-bottom: 0.5rem;
}
#previewOutput h2 {
color: #34495e;
border-bottom: 1px solid #bdc3c7;
padding-bottom: 0.3rem;
}
#previewOutput h3 {
color: #5d6d7e;
}
#previewOutput table {
width: 100%;
border-collapse: collapse;
margin: 1rem 0;
}
#previewOutput table,
#previewOutput th,
#previewOutput td {
border: 1px solid #bdc3c7;
}
#previewOutput th,
#previewOutput td {
padding: 0.5rem;
text-align: left;
}
#previewOutput th {
background-color: #ecf0f1;
font-weight: 600;
}
#previewOutput pre {
.preview-pane {
flex: 1;
padding: 48px;
background-color: #f8f9fa;
border: 1px solid #e9ecef;
border-radius: 0.375rem;
padding: 1rem;
overflow-x: auto;
overflow-y: auto;
}
#previewOutput code {
background-color: #f1f2f6;
padding: 0.2rem 0.4rem;
border-radius: 0.25rem;
font-family: 'Courier New', monospace;
.preview-output {
max-width: 8.5in;
margin: 0 auto;
background-color: #fff;
padding: 1in;
box-shadow: 0 0 10px rgba(0,0,0,0.1);
}
#previewOutput blockquote {
border-left: 4px solid #3498db;
margin: 1rem 0;
padding-left: 1rem;
color: #7f8c8d;
font-style: italic;
.styles-pane {
width: 250px;
padding: 20px;
background-color: #edf2fa;
border-left: 1px solid #dadce0;
}
#previewOutput img {
max-width: 100%;
height: auto;
margin: 1rem 0;
#styles-list {
margin-top: 20px;
}
/* Style preview */
#stylePreview {
min-height: 100px;
border: 1px dashed #ced4da;
padding: 0.5rem;
border-radius: 0.25rem;
.style-item {
padding: 10px;
margin-bottom: 10px;
background-color: #fff;
border: 1px solid #dadce0;
cursor: pointer;
}
/* Responsive adjustments */
@media (max-width: 768px) {
.container-fluid {
padding: 0.5rem;
}
.row > div {
margin-bottom: 1rem;
}
.style-item:hover {
background-color: #d2e3fc;
}
/* Table pagination styles */
.table-pagination-header {
background-color: #ecf0f1;
.modal {
display: none;
position: fixed;
z-index: 1;
left: 0;
top: 0;
width: 100%;
height: 100%;
overflow: auto;
background-color: rgba(0,0,0,0.4);
}
.modal-content {
background-color: #fefefe;
margin: 15% auto;
padding: 20px;
border: 1px solid #888;
width: 80%;
max-width: 500px;
}
.close-button {
color: #aaa;
float: right;
font-size: 28px;
font-weight: bold;
border-top: 2px solid #3498db !important;
}
/* Caption styles */
.caption {
font-size: 0.9rem;
color: #7f8c8d;
text-align: center;
margin-top: 0.5rem;
font-style: italic;
.close-button:hover,
.close-button:focus {
color: black;
text-decoration: none;
cursor: pointer;
}
/* Button styling */
.btn-outline-secondary.btn-sm {
padding: 0.25rem 0.5rem;
font-size: 0.875rem;
}
/* Scrollbar styling */
.preview-container::-webkit-scrollbar {
width: 8px;
}
.preview-container::-webkit-scrollbar-track {
background: #f1f1f1;
border-radius: 4px;
}
.preview-container::-webkit-scrollbar-thumb {
background: #c1c1c1;
border-radius: 4px;
}
.preview-container::-webkit-scrollbar-thumb:hover {
background: #a8a8a8;
}