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:
@@ -1,223 +1,324 @@
|
|||||||
from flask import Flask, render_template, request, send_file, jsonify
|
from flask import Flask, render_template, request, send_file, jsonify
|
||||||
import markdown
|
import markdown
|
||||||
from docx import Document
|
from odfdo import Document, Paragraph, Header, ListItem, List, Table, Row as TableRow, Cell as TableCell, Span, Style, Frame, Image
|
||||||
from docx.shared import Inches, Pt
|
|
||||||
from docx.enum.text import WD_ALIGN_PARAGRAPH
|
|
||||||
from docx.enum.style import WD_STYLE_TYPE
|
|
||||||
from docx.oxml.ns import qn
|
|
||||||
import io
|
import io
|
||||||
import json
|
import json
|
||||||
import re
|
import re
|
||||||
from werkzeug.utils import secure_filename
|
import requests
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
app = Flask(__name__)
|
app = Flask(__name__)
|
||||||
|
|
||||||
# Global variables for tracking captions and table pagination
|
ALLOWED_IMAGE_DOMAINS = ["upload.wikimedia.org"]
|
||||||
image_counter = 0
|
|
||||||
table_counter = 0
|
|
||||||
|
|
||||||
def add_custom_styles(doc):
|
# Load styles from file or create default styles
|
||||||
"""Add custom styles to the document"""
|
try:
|
||||||
# Heading 1 style
|
with open('styles.json', 'r') as f:
|
||||||
if "Heading 1" not in doc.styles:
|
styles = json.load(f)
|
||||||
h1 = doc.styles.add_style('Heading 1', WD_STYLE_TYPE.PARAGRAPH)
|
except FileNotFoundError:
|
||||||
h1.font.name = 'Arial'
|
styles = {
|
||||||
h1.font.size = Pt(16)
|
"Normal": {
|
||||||
h1.font.bold = True
|
"font_size": "12pt",
|
||||||
h1.paragraph_format.space_after = Pt(12)
|
"font_family": "Liberation Serif",
|
||||||
|
"margin_bottom": "0.1in"
|
||||||
# Heading 2 style
|
},
|
||||||
if "Heading 2" not in doc.styles:
|
"Heading 1": {
|
||||||
h2 = doc.styles.add_style('Heading 2', WD_STYLE_TYPE.PARAGRAPH)
|
"font_size": "18pt",
|
||||||
h2.font.name = 'Arial'
|
"font_family": "Liberation Sans",
|
||||||
h2.font.size = Pt(14)
|
"font_weight": "bold",
|
||||||
h2.font.bold = True
|
"margin_bottom": "0.2in"
|
||||||
h2.paragraph_format.space_after = Pt(10)
|
},
|
||||||
|
"Heading 2": {
|
||||||
# Heading 3 style
|
"font_size": "14pt",
|
||||||
if "Heading 3" not in doc.styles:
|
"font_family": "Liberation Sans",
|
||||||
h3 = doc.styles.add_style('Heading 3', WD_STYLE_TYPE.PARAGRAPH)
|
"font_weight": "bold",
|
||||||
h3.font.name = 'Arial'
|
"margin_bottom": "0.15in"
|
||||||
h3.font.size = Pt(12)
|
},
|
||||||
h3.font.bold = True
|
"Caption": {
|
||||||
h3.paragraph_format.space_after = Pt(8)
|
"font_size": "10pt",
|
||||||
|
"font_family": "Liberation Serif",
|
||||||
# Quote style
|
"font_style": "italic",
|
||||||
if "Quote" not in doc.styles:
|
"text_align": "center"
|
||||||
quote = doc.styles.add_style('Quote', WD_STYLE_TYPE.PARAGRAPH)
|
},
|
||||||
quote.font.name = 'Arial'
|
"Code": {
|
||||||
quote.font.size = Pt(11)
|
"font_family": "Courier New",
|
||||||
quote.font.italic = True
|
"font_size": "10pt",
|
||||||
quote.paragraph_format.left_indent = Inches(0.5)
|
"background_color": "#f0f0f0",
|
||||||
quote.paragraph_format.right_indent = Inches(0.5)
|
"padding": "10px"
|
||||||
quote.paragraph_format.line_spacing = 1.1
|
},
|
||||||
|
"Quote": {
|
||||||
|
"font_style": "italic",
|
||||||
|
"border_left": "3px solid #ccc",
|
||||||
|
"padding_left": "10px"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
with open('styles.json', 'w') as f:
|
||||||
|
json.dump(styles, f, indent=4)
|
||||||
|
|
||||||
def convert_md_to_docx(markdown_text, config):
|
@app.route('/styles', methods=['GET'])
|
||||||
"""Convert Markdown text to Word document with custom settings"""
|
def get_styles():
|
||||||
global image_counter, table_counter
|
return jsonify(styles)
|
||||||
image_counter = 0
|
|
||||||
|
@app.route('/styles/<style_name>', methods=['GET', 'POST'])
|
||||||
|
def manage_style(style_name):
|
||||||
|
if request.method == 'POST':
|
||||||
|
data = request.json
|
||||||
|
styles[style_name] = data
|
||||||
|
with open('styles.json', 'w') as f:
|
||||||
|
json.dump(styles, f, indent=4)
|
||||||
|
return jsonify({"message": f"Style '{style_name}' updated successfully."})
|
||||||
|
else:
|
||||||
|
return jsonify(styles.get(style_name, {}))
|
||||||
|
|
||||||
|
def generate_toc(markdown_text):
|
||||||
|
toc = []
|
||||||
|
for line in markdown_text.splitlines():
|
||||||
|
if line.startswith('# '):
|
||||||
|
toc.append((1, line[2:].strip()))
|
||||||
|
elif line.startswith('## '):
|
||||||
|
toc.append((2, line[3:].strip()))
|
||||||
|
return toc
|
||||||
|
|
||||||
|
def apply_styles_to_doc(doc):
|
||||||
|
for name, properties in styles.items():
|
||||||
|
style = Style(
|
||||||
|
'paragraph',
|
||||||
|
name=name,
|
||||||
|
**{
|
||||||
|
"fo:font-size": properties.get("font_size"),
|
||||||
|
"fo:font-family": properties.get("font_family"),
|
||||||
|
"fo:font-weight": properties.get("font_weight"),
|
||||||
|
"fo:font-style": properties.get("font_style"),
|
||||||
|
"fo:text-align": properties.get("text_align"),
|
||||||
|
"fo:border-left": properties.get("border_left"),
|
||||||
|
"fo:padding-left": properties.get("padding_left"),
|
||||||
|
"fo:background-color": properties.get("background_color"),
|
||||||
|
"fo:padding": properties.get("padding"),
|
||||||
|
"fo:margin-bottom": properties.get("margin_bottom")
|
||||||
|
}
|
||||||
|
)
|
||||||
|
doc.insert_style(style, automatic=True)
|
||||||
|
|
||||||
|
def convert_md_to_odt(markdown_text, applied_styles, max_rows=10):
|
||||||
|
doc = Document('text')
|
||||||
|
apply_styles_to_doc(doc)
|
||||||
|
body = doc.body
|
||||||
table_counter = 0
|
table_counter = 0
|
||||||
|
image_counter = 0
|
||||||
# Create a new document
|
|
||||||
doc = Document()
|
lines = markdown_text.splitlines()
|
||||||
|
i = 0
|
||||||
# Add custom styles
|
toc_placeholder_index = -1
|
||||||
add_custom_styles(doc)
|
for idx, line in enumerate(lines):
|
||||||
|
if "[TOC]" in line:
|
||||||
# Parse markdown into lines
|
toc_placeholder_index = idx
|
||||||
lines = markdown_text.split('\n')
|
break
|
||||||
|
|
||||||
|
headings = generate_toc(markdown_text)
|
||||||
|
|
||||||
i = 0
|
i = 0
|
||||||
while i < len(lines):
|
while i < len(lines):
|
||||||
line = lines[i].strip()
|
line = lines[i]
|
||||||
|
style_name = applied_styles.get(str(i), "Normal")
|
||||||
|
|
||||||
|
if i == toc_placeholder_index:
|
||||||
|
toc_list = List()
|
||||||
|
for level, text in headings:
|
||||||
|
item = ListItem()
|
||||||
|
p = Paragraph()
|
||||||
|
bookmark_name = f"__RefHeading_{hash(text)}"
|
||||||
|
link = Span(text)
|
||||||
|
link.set_attribute('xlink:href', f"#{bookmark_name}")
|
||||||
|
link.set_attribute('xlink:type', 'simple')
|
||||||
|
p.append(link)
|
||||||
|
item.append(p)
|
||||||
|
toc_list.append(item)
|
||||||
|
body.append(toc_list)
|
||||||
|
i += 1
|
||||||
|
continue
|
||||||
|
|
||||||
if line.startswith('# '):
|
if line.startswith('# '):
|
||||||
# Heading 1
|
text = line[2:].strip()
|
||||||
doc.add_heading(line[2:], level=0)
|
h = Header(1, text=text)
|
||||||
|
h.style = style_name
|
||||||
|
bookmark_name = f"__RefHeading_{hash(text)}"
|
||||||
|
h.set_bookmark(bookmark_name)
|
||||||
|
body.append(h)
|
||||||
elif line.startswith('## '):
|
elif line.startswith('## '):
|
||||||
# Heading 2
|
text = line[3:].strip()
|
||||||
doc.add_heading(line[3:], level=1)
|
h = Header(2, text=text)
|
||||||
elif line.startswith('### '):
|
h.style = style_name
|
||||||
# Heading 3
|
bookmark_name = f"__RefHeading_{hash(text)}"
|
||||||
doc.add_heading(line[4:], level=2)
|
h.set_bookmark(bookmark_name)
|
||||||
elif line.startswith('> '):
|
body.append(h)
|
||||||
# Blockquote
|
elif line.startswith('- '):
|
||||||
p = doc.add_paragraph(line[2:], style='Quote')
|
odt_list = List()
|
||||||
elif line.startswith('|') and i + 1 < len(lines) and lines[i + 1].strip().startswith('|'):
|
while i < len(lines) and lines[i].strip().startswith('- '):
|
||||||
# Table processing
|
item_style = applied_styles.get(str(i), "Normal")
|
||||||
|
odt_list.append(ListItem(Paragraph(text=lines[i].strip()[2:], style=item_style)))
|
||||||
|
i += 1
|
||||||
|
body.append(odt_list)
|
||||||
|
i -= 1
|
||||||
|
elif line.startswith('|'):
|
||||||
table_lines = []
|
table_lines = []
|
||||||
while i < len(lines) and lines[i].strip().startswith('|'):
|
while i < len(lines) and lines[i].strip().startswith('|'):
|
||||||
table_lines.append(lines[i].strip())
|
table_lines.append(lines[i].strip())
|
||||||
i += 1
|
i += 1
|
||||||
|
|
||||||
# Process the table
|
if len(table_lines) > 1:
|
||||||
if table_lines:
|
headers = [h.strip() for h in table_lines[0].strip('|').split('|')]
|
||||||
headers = [cell.strip() for cell in table_lines[0].strip('|').split('|')]
|
table_counter += 1
|
||||||
separators = [cell.strip() for cell in table_lines[1].strip('|').split('|')]
|
|
||||||
rows = []
|
rows = table_lines[2:]
|
||||||
|
num_rows = len(rows)
|
||||||
|
|
||||||
for j in range(2, len(table_lines)):
|
for j in range(0, num_rows, max_rows):
|
||||||
row = [cell.strip() for cell in table_lines[j].strip('|').split('|')]
|
chunk = rows[j:j + max_rows]
|
||||||
rows.append(row)
|
|
||||||
|
chunk_table = Table(name=f"Table{table_counter}_{j}", width=len(headers))
|
||||||
# Create table
|
|
||||||
table = doc.add_table(rows=1, cols=len(headers))
|
header_row = TableRow()
|
||||||
table.style = 'Table Grid'
|
for header in headers:
|
||||||
|
cell = TableCell()
|
||||||
# Add headers
|
cell.append(Paragraph(text=header))
|
||||||
hdr_cells = table.rows[0].cells
|
header_row.append(cell)
|
||||||
for j, header in enumerate(headers):
|
chunk_table.append(header_row)
|
||||||
if j < len(hdr_cells):
|
|
||||||
hdr_cells[j].text = header
|
for row_line in chunk:
|
||||||
|
row_cells = [c.strip() for c in row_line.strip('|').split('|')]
|
||||||
# Add data rows
|
row = TableRow()
|
||||||
for row_data in rows:
|
for cell_text in row_cells:
|
||||||
row_cells = table.add_row().cells
|
cell = TableCell()
|
||||||
for j, cell_data in enumerate(row_data):
|
cell.append(Paragraph(text=cell_text))
|
||||||
if j < len(row_cells):
|
row.append(cell)
|
||||||
row_cells[j].text = cell_data
|
chunk_table.append(row)
|
||||||
|
body.append(chunk_table)
|
||||||
# Add table caption if enabled
|
|
||||||
if config.get('autoCaptionTables', True):
|
caption_text = f"Table {table_counter}"
|
||||||
table_counter += 1
|
if j > 0:
|
||||||
caption_para = doc.add_paragraph()
|
caption_text += " (continued)"
|
||||||
caption_run = caption_para.add_run(f'Table {table_counter}: ')
|
caption = Paragraph(caption_text, style="Caption")
|
||||||
caption_run.italic = True
|
body.append(caption)
|
||||||
caption_run.font.size = Pt(10)
|
elif line.startswith('!['):
|
||||||
caption_run.font.name = 'Arial'
|
match = re.match(r'!\[(.*?)\]\((.*?)\)', line)
|
||||||
caption_para.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
if match:
|
||||||
|
alt_text = match.group(1)
|
||||||
|
image_url = match.group(2)
|
||||||
|
|
||||||
|
try:
|
||||||
|
domain = urlparse(image_url).netloc
|
||||||
|
if domain not in ALLOWED_IMAGE_DOMAINS:
|
||||||
|
raise ValueError("Image domain not allowed.")
|
||||||
|
|
||||||
|
response = requests.get(image_url, stream=True, timeout=10)
|
||||||
|
response.raise_for_status()
|
||||||
|
|
||||||
|
# Check for content length
|
||||||
|
content_length = response.headers.get('content-length')
|
||||||
|
if content_length and int(content_length) > 10 * 1024 * 1024:
|
||||||
|
raise ValueError("Image file is too large.")
|
||||||
|
|
||||||
|
with tempfile.NamedTemporaryFile(delete=False, suffix=".jpg") as temp_file:
|
||||||
|
for chunk in response.iter_content(chunk_size=8192):
|
||||||
|
temp_file.write(chunk)
|
||||||
|
|
||||||
|
# Add the image to the document
|
||||||
|
image_data = doc.add_file(temp_file.name)
|
||||||
|
|
||||||
|
# Create a frame and insert the image
|
||||||
|
frame = Frame(width="10cm", height="10cm")
|
||||||
|
frame.append(Image(url=image_data))
|
||||||
|
body.append(frame)
|
||||||
|
|
||||||
|
# Add the caption
|
||||||
|
image_counter += 1
|
||||||
|
caption = Paragraph(f"Figure {image_counter}: {alt_text}", style="Caption")
|
||||||
|
body.append(caption)
|
||||||
|
|
||||||
|
# Clean up the temporary file
|
||||||
|
os.unlink(temp_file.name)
|
||||||
|
|
||||||
|
except requests.exceptions.RequestException as e:
|
||||||
|
p = Paragraph(f"[Image not found: {alt_text}]", style="Normal")
|
||||||
|
body.append(p)
|
||||||
elif line.startswith('```'):
|
elif line.startswith('```'):
|
||||||
# Code block
|
|
||||||
code_lines = []
|
code_lines = []
|
||||||
lang = line[3:] if len(line) > 3 else ''
|
|
||||||
i += 1
|
i += 1
|
||||||
while i < len(lines) and not lines[i].startswith('```'):
|
while i < len(lines) and not lines[i].startswith('```'):
|
||||||
code_lines.append(lines[i])
|
code_lines.append(lines[i])
|
||||||
i += 1
|
i += 1
|
||||||
code_block = '\n'.join(code_lines)
|
code_block = '\n'.join(code_lines)
|
||||||
|
p = Paragraph(code_block, style="Code")
|
||||||
p = doc.add_paragraph()
|
body.append(p)
|
||||||
p.alignment = WD_ALIGN_PARAGRAPH.LEFT
|
elif line.startswith('> '):
|
||||||
run = p.add_run(code_block)
|
p = Paragraph(line[2:], style="Quote")
|
||||||
run.font.name = 'Courier New'
|
body.append(p)
|
||||||
run.font.size = Pt(10)
|
elif line:
|
||||||
|
p = Paragraph(style=style_name)
|
||||||
# Apply shading to the paragraph - simplified approach
|
parts = re.split(r'(\\*\\*.*\\*\\*|\\*.*\\*)', line)
|
||||||
# For now, just add the code block as formatted text
|
for part in parts:
|
||||||
pass
|
if part.startswith('**') and part.endswith('**'):
|
||||||
|
p.append(Span(part[2:-2], style='bold'))
|
||||||
elif line.startswith('- ') or line.startswith('* '):
|
elif part.startswith('*') and part.endswith('*'):
|
||||||
# List item
|
p.append(Span(part[1:-1], style='italic'))
|
||||||
p = doc.add_paragraph(line[2:], style='List Bullet')
|
else:
|
||||||
elif line.startswith('
|
|
||||||
match = re.match(r'!\[(.*?)\]\((.*?)\)', line)
|
|
||||||
if match:
|
|
||||||
alt_text = match.group(1)
|
|
||||||
img_url = match.group(2)
|
|
||||||
|
|
||||||
# Add image if it's a valid local file
|
|
||||||
try:
|
|
||||||
# For this example, we'll just add a placeholder since we don't have actual image files
|
|
||||||
p = doc.add_paragraph('Image Placeholder: ' + alt_text)
|
|
||||||
p.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
|
||||||
|
|
||||||
# Add caption if enabled
|
|
||||||
if config.get('autoCaptionImages', True):
|
|
||||||
image_counter += 1
|
|
||||||
caption_para = doc.add_paragraph()
|
|
||||||
caption_run = caption_para.add_run(f'Figure {image_counter}: {alt_text}')
|
|
||||||
caption_run.italic = True
|
|
||||||
caption_run.font.size = Pt(10)
|
|
||||||
caption_run.font.name = 'Arial'
|
|
||||||
caption_para.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
|
||||||
|
|
||||||
except:
|
|
||||||
# If image can't be added, just add alt text
|
|
||||||
doc.add_paragraph(alt_text)
|
|
||||||
elif line == '':
|
|
||||||
# Empty line - add spacing
|
|
||||||
doc.add_paragraph('')
|
|
||||||
else:
|
|
||||||
# Regular paragraph
|
|
||||||
if line:
|
|
||||||
doc.add_paragraph(line)
|
|
||||||
|
|
||||||
i += 1
|
i += 1
|
||||||
|
|
||||||
return doc
|
return doc
|
||||||
|
|
||||||
@app.route('/')
|
@app.route('/')
|
||||||
def index():
|
def index():
|
||||||
return render_template('index.html')
|
return render_template('index.html')
|
||||||
|
|
||||||
|
@app.route('/preview', methods=['POST'])
|
||||||
|
def preview():
|
||||||
|
data = request.json
|
||||||
|
markdown_text = data.get('markdown', '')
|
||||||
|
applied_styles = data.get('styles', {})
|
||||||
|
|
||||||
|
# Generate CSS from styles
|
||||||
|
css = ""
|
||||||
|
for name, properties in styles.items():
|
||||||
|
css += f".style-{name.replace(' ', '-')} {{"
|
||||||
|
for prop, value in properties.items():
|
||||||
|
css += f"{prop.replace('_', '-')}: {value};"
|
||||||
|
css += "}"
|
||||||
|
|
||||||
|
# Generate styled HTML
|
||||||
|
html = markdown.markdown(markdown_text, extensions=['tables'])
|
||||||
|
lines = html.splitlines()
|
||||||
|
styled_html = ""
|
||||||
|
for i, line in enumerate(lines):
|
||||||
|
style_name = applied_styles.get(str(i), "Normal").replace(' ', '-')
|
||||||
|
styled_html += f"<div class='style-{style_name}'>{line}</div>"
|
||||||
|
|
||||||
|
full_html = f"<style>{css}</style>{styled_html}"
|
||||||
|
|
||||||
|
return jsonify({'html': full_html})
|
||||||
|
|
||||||
@app.route('/export', methods=['POST'])
|
@app.route('/export', methods=['POST'])
|
||||||
def export():
|
def export():
|
||||||
markdown_text = request.form.get('markdown', '')
|
data = request.json
|
||||||
config_json = request.form.get('config', '{}')
|
markdown_text = data.get('markdown', '')
|
||||||
|
applied_styles = data.get('styles', {})
|
||||||
|
|
||||||
try:
|
doc = convert_md_to_odt(markdown_text, applied_styles)
|
||||||
config = json.loads(config_json)
|
|
||||||
except:
|
|
||||||
config = {}
|
|
||||||
|
|
||||||
# Convert markdown to docx
|
|
||||||
doc = convert_md_to_docx(markdown_text, config)
|
|
||||||
|
|
||||||
# Save to BytesIO object
|
|
||||||
file_stream = io.BytesIO()
|
file_stream = io.BytesIO()
|
||||||
doc.save(file_stream)
|
doc.save(file_stream, pretty=True)
|
||||||
file_stream.seek(0)
|
file_stream.seek(0)
|
||||||
|
|
||||||
# Return the file for download
|
|
||||||
return send_file(
|
return send_file(
|
||||||
file_stream,
|
file_stream,
|
||||||
as_attachment=True,
|
as_attachment=True,
|
||||||
download_name='document.docx',
|
download_name='document.odt',
|
||||||
mimetype='application/vnd.openxmlformats-officedocument.wordprocessingml.document'
|
mimetype='application/vnd.oasis.opendocument.text'
|
||||||
)
|
)
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
app.run(debug=True, host='0.0.0.0', port=5000)
|
app.run(debug=True, host='0.0.0.0', port=5000)
|
||||||
|
|||||||
+4
-2
@@ -1,3 +1,5 @@
|
|||||||
Flask==2.3.3
|
Flask==2.3.3
|
||||||
python-docx==0.8.11
|
gunicorn==23.0.0
|
||||||
markdown==3.4.4
|
markdown==3.4.4
|
||||||
|
odfdo==3.17.6
|
||||||
|
requests
|
||||||
|
|||||||
+144
-285
@@ -1,298 +1,157 @@
|
|||||||
// GhostEditor - Markdown to Word Converter JavaScript
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
const markdownInput = document.getElementById('markdown-input');
|
||||||
// DOM Elements
|
const previewOutput = document.getElementById('preview-output');
|
||||||
const markdownInput = document.getElementById('markdownInput');
|
const stylesList = document.getElementById('styles-list');
|
||||||
const previewOutput = document.getElementById('previewOutput');
|
const exportButton = document.getElementById('export-odt');
|
||||||
const previewToggle = document.getElementById('previewToggle');
|
const addTocButton = document.getElementById('add-toc');
|
||||||
const exportBtn = document.getElementById('exportBtn');
|
const addStyleButton = document.getElementById('add-style');
|
||||||
const blockType = document.getElementById('blockType');
|
const styleEditorModal = document.getElementById('style-editor-modal');
|
||||||
const applyStyle = document.getElementById('applyStyle');
|
const styleEditorForm = document.getElementById('style-editor-form');
|
||||||
const applyStyleBtn = document.getElementById('applyStyleBtn');
|
const styleModalTitle = document.getElementById('style-modal-title');
|
||||||
const customCaption = document.getElementById('customCaption');
|
const styleNameInput = document.getElementById('style-name-input');
|
||||||
const applyCaptionBtn = document.getElementById('applyCaptionBtn');
|
const fontFamilyInput = document.getElementById('font-family-input');
|
||||||
const autoCaptionImages = document.getElementById('autoCaptionImages');
|
const fontSizeInput = document.getElementById('font-size-input');
|
||||||
const autoCaptionTables = document.getElementById('autoCaptionTables');
|
const fontWeightInput = document.getElementById('font-weight-input');
|
||||||
const autoCaptionFigures = document.getElementById('autoCaptionFigures');
|
const fontStyleInput = document.getElementById('font-style-input');
|
||||||
const tablePagination = document.getElementById('tablePagination');
|
const textAlignInput = document.getElementById('text-align-input');
|
||||||
const boldBtn = document.getElementById('boldBtn');
|
const colorInput = document.getElementById('color-input');
|
||||||
const italicBtn = document.getElementById('italicBtn');
|
const backgroundColorInput = document.getElementById('background-color-input');
|
||||||
const headingBtn = document.getElementById('headingBtn');
|
const paddingInput = document.getElementById('padding-input');
|
||||||
const listBtn = document.getElementById('listBtn');
|
const borderLeftInput = document.getElementById('border-left-input');
|
||||||
const imageBtn = document.getElementById('imageBtn');
|
const paddingLeftInput = document.getElementById('padding-left-input');
|
||||||
|
const marginBottomInput = document.getElementById('margin-bottom-input');
|
||||||
// Configuration
|
const closeButton = document.querySelector('.close-button');
|
||||||
let config = {
|
const boldButton = document.getElementById('bold-button');
|
||||||
autoCaptionImages: true,
|
const italicButton = document.getElementById('italic-button');
|
||||||
autoCaptionTables: true,
|
const h1Button = document.getElementById('h1-button');
|
||||||
autoCaptionFigures: true,
|
const h2Button = document.getElementById('h2-button');
|
||||||
tablePagination: true,
|
|
||||||
defaultStyle: 'Normal'
|
|
||||||
};
|
|
||||||
|
|
||||||
// Initialize with sample markdown
|
|
||||||
markdownInput.value = `# GhostEditor Sample Document
|
|
||||||
|
|
||||||
This is a sample document to demonstrate the real-time conversion capabilities of GhostEditor.
|
let styles = {};
|
||||||
|
let appliedStyles = {};
|
||||||
|
|
||||||
## Features Demonstrated
|
async function fetchStyles() {
|
||||||
|
const response = await fetch('/styles');
|
||||||
### Auto-captioning
|
styles = await response.json();
|
||||||

|
renderStyles();
|
||||||
*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();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update preview function
|
function renderStyles() {
|
||||||
function updatePreview() {
|
stylesList.innerHTML = '';
|
||||||
if (!previewToggle.checked) return;
|
for (const name in styles) {
|
||||||
|
const styleItem = document.createElement('div');
|
||||||
const markdown = markdownInput.value;
|
styleItem.className = 'style-item';
|
||||||
|
styleItem.textContent = name;
|
||||||
// Convert markdown to HTML using Marked.js
|
styleItem.addEventListener('click', () => applyStyleToSelection(name));
|
||||||
const html = marked.parse(markdown);
|
stylesList.appendChild(styleItem);
|
||||||
|
|
||||||
// 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();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Process HTML for preview with special features
|
function applyStyleToSelection(styleName) {
|
||||||
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) {
|
|
||||||
const start = markdownInput.selectionStart;
|
const start = markdownInput.selectionStart;
|
||||||
const end = markdownInput.selectionEnd;
|
const end = markdownInput.selectionEnd;
|
||||||
const selectedText = markdownInput.value.substring(start, end);
|
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);
|
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();
|
updatePreview();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wrap current line with markdown syntax
|
boldButton.addEventListener('click', () => applyMarkdownFormatting('**'));
|
||||||
function wrapLine(prefix) {
|
italicButton.addEventListener('click', () => applyMarkdownFormatting('*'));
|
||||||
const start = markdownInput.selectionStart;
|
h1Button.addEventListener('click', () => applyMarkdownFormatting('# '));
|
||||||
const end = markdownInput.selectionEnd;
|
h2Button.addEventListener('click', () => applyMarkdownFormatting('## '));
|
||||||
const text = markdownInput.value;
|
|
||||||
|
async function updatePreview() {
|
||||||
// Find start and end of current line
|
const markdown = markdownInput.value;
|
||||||
const lineStart = text.lastIndexOf('\n', start - 1) + 1;
|
const response = await fetch('/preview', {
|
||||||
const lineEnd = text.indexOf('\n', end);
|
method: 'POST',
|
||||||
const actualLineEnd = lineEnd === -1 ? text.length : lineEnd;
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
const lineText = text.substring(lineStart, actualLineEnd);
|
},
|
||||||
const newLineText = prefix + lineText;
|
body: JSON.stringify({ markdown, styles: appliedStyles }),
|
||||||
|
});
|
||||||
markdownInput.value = text.substring(0, lineStart) + newLineText + text.substring(actualLineEnd);
|
const data = await response.json();
|
||||||
|
previewOutput.innerHTML = data.html;
|
||||||
// Update cursor position
|
}
|
||||||
markdownInput.selectionStart = start + prefix.length;
|
|
||||||
markdownInput.selectionEnd = end + prefix.length;
|
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();
|
updatePreview();
|
||||||
}
|
});
|
||||||
|
|
||||||
// Insert image markdown
|
addStyleButton.addEventListener('click', () => {
|
||||||
function insertImage() {
|
styleModalTitle.textContent = 'Add New Style';
|
||||||
const start = markdownInput.selectionStart;
|
styleEditorForm.reset();
|
||||||
const end = markdownInput.selectionEnd;
|
styleNameInput.value = '';
|
||||||
const selectedText = markdownInput.value.substring(start, end);
|
styleEditorModal.style.display = 'block';
|
||||||
|
});
|
||||||
const altText = selectedText || 'image';
|
|
||||||
const imageMd = ``;
|
closeButton.addEventListener('click', () => {
|
||||||
|
styleEditorModal.style.display = 'none';
|
||||||
markdownInput.value = markdownInput.value.substring(0, start) + imageMd + markdownInput.value.substring(end);
|
});
|
||||||
|
|
||||||
// Update cursor position
|
styleEditorForm.addEventListener('submit', async (e) => {
|
||||||
markdownInput.selectionStart = start + 2; // Position cursor in the alt text
|
e.preventDefault();
|
||||||
markdownInput.selectionEnd = start + 2 + altText.length;
|
const name = styleNameInput.value;
|
||||||
|
const style = {
|
||||||
updatePreview();
|
'font-family': fontFamilyInput.value,
|
||||||
}
|
'font-size': fontSizeInput.value,
|
||||||
|
'font-weight': fontWeightInput.value,
|
||||||
// Export to Word functionality
|
'font-style': fontStyleInput.value,
|
||||||
function exportToWord() {
|
'text-align': textAlignInput.value,
|
||||||
// In a real implementation, this would send the markdown to the backend
|
'color': colorInput.value,
|
||||||
// to convert it to Word format
|
'background-color': backgroundColorInput.value,
|
||||||
alert('Exporting to Word document...');
|
'padding': paddingInput.value,
|
||||||
|
'border-left': borderLeftInput.value,
|
||||||
// Create a form to submit the markdown to the backend
|
'padding-left': paddingLeftInput.value,
|
||||||
const form = document.createElement('form');
|
'margin-bottom': marginBottomInput.value,
|
||||||
form.method = 'POST';
|
};
|
||||||
form.action = '/export';
|
await fetch(`/styles/${name}`, {
|
||||||
form.style.display = 'none';
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
const markdownField = document.createElement('input');
|
'Content-Type': 'application/json',
|
||||||
markdownField.type = 'hidden';
|
},
|
||||||
markdownField.name = 'markdown';
|
body: JSON.stringify(style),
|
||||||
markdownField.value = markdownInput.value;
|
});
|
||||||
|
styleEditorModal.style.display = 'none';
|
||||||
const configField = document.createElement('input');
|
fetchStyles();
|
||||||
configField.type = 'hidden';
|
});
|
||||||
configField.name = 'config';
|
|
||||||
configField.value = JSON.stringify(config);
|
fetchStyles();
|
||||||
|
});
|
||||||
form.appendChild(markdownField);
|
|
||||||
form.appendChild(configField);
|
|
||||||
document.body.appendChild(form);
|
|
||||||
form.submit();
|
|
||||||
document.body.removeChild(form);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize style preview
|
|
||||||
showStylePreview(config.defaultStyle);
|
|
||||||
});
|
|
||||||
|
|||||||
+95
-137
@@ -1,165 +1,123 @@
|
|||||||
/* GhostEditor - Markdown to Word Converter Styles */
|
|
||||||
body {
|
body {
|
||||||
|
font-family: 'Arial', sans-serif;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
background-color: #f8f9fa;
|
background-color: #f8f9fa;
|
||||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
color: #202124;
|
||||||
}
|
}
|
||||||
|
|
||||||
.card {
|
.main-container {
|
||||||
border: 1px solid #dee2e6;
|
display: flex;
|
||||||
border-radius: 0.5rem;
|
height: 100vh;
|
||||||
box-shadow: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-header {
|
.editor-pane {
|
||||||
background-color: #e9ecef;
|
flex: 1;
|
||||||
border-bottom: 1px solid #dee2e6;
|
display: flex;
|
||||||
font-weight: 600;
|
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;
|
resize: none;
|
||||||
height: 500px;
|
background-color: #fff;
|
||||||
font-family: 'Courier New', monospace;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.preview-container {
|
.preview-pane {
|
||||||
min-height: 500px;
|
flex: 1;
|
||||||
background-color: white;
|
padding: 48px;
|
||||||
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 {
|
|
||||||
background-color: #f8f9fa;
|
background-color: #f8f9fa;
|
||||||
border: 1px solid #e9ecef;
|
overflow-y: auto;
|
||||||
border-radius: 0.375rem;
|
|
||||||
padding: 1rem;
|
|
||||||
overflow-x: auto;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#previewOutput code {
|
.preview-output {
|
||||||
background-color: #f1f2f6;
|
max-width: 8.5in;
|
||||||
padding: 0.2rem 0.4rem;
|
margin: 0 auto;
|
||||||
border-radius: 0.25rem;
|
background-color: #fff;
|
||||||
font-family: 'Courier New', monospace;
|
padding: 1in;
|
||||||
|
box-shadow: 0 0 10px rgba(0,0,0,0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
#previewOutput blockquote {
|
.styles-pane {
|
||||||
border-left: 4px solid #3498db;
|
width: 250px;
|
||||||
margin: 1rem 0;
|
padding: 20px;
|
||||||
padding-left: 1rem;
|
background-color: #edf2fa;
|
||||||
color: #7f8c8d;
|
border-left: 1px solid #dadce0;
|
||||||
font-style: italic;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#previewOutput img {
|
#styles-list {
|
||||||
max-width: 100%;
|
margin-top: 20px;
|
||||||
height: auto;
|
|
||||||
margin: 1rem 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Style preview */
|
.style-item {
|
||||||
#stylePreview {
|
padding: 10px;
|
||||||
min-height: 100px;
|
margin-bottom: 10px;
|
||||||
border: 1px dashed #ced4da;
|
background-color: #fff;
|
||||||
padding: 0.5rem;
|
border: 1px solid #dadce0;
|
||||||
border-radius: 0.25rem;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Responsive adjustments */
|
.style-item:hover {
|
||||||
@media (max-width: 768px) {
|
background-color: #d2e3fc;
|
||||||
.container-fluid {
|
|
||||||
padding: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.row > div {
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Table pagination styles */
|
.modal {
|
||||||
.table-pagination-header {
|
display: none;
|
||||||
background-color: #ecf0f1;
|
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;
|
font-weight: bold;
|
||||||
border-top: 2px solid #3498db !important;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Caption styles */
|
.close-button:hover,
|
||||||
.caption {
|
.close-button:focus {
|
||||||
font-size: 0.9rem;
|
color: black;
|
||||||
color: #7f8c8d;
|
text-decoration: none;
|
||||||
text-align: center;
|
cursor: pointer;
|
||||||
margin-top: 0.5rem;
|
|
||||||
font-style: italic;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 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;
|
|
||||||
}
|
|
||||||
+54
@@ -0,0 +1,54 @@
|
|||||||
|
{
|
||||||
|
"Normal": {
|
||||||
|
"font_size": "12pt",
|
||||||
|
"font_family": "Liberation Serif",
|
||||||
|
"margin_bottom": "0.1in"
|
||||||
|
},
|
||||||
|
"Heading 1": {
|
||||||
|
"font_size": "18pt",
|
||||||
|
"font_family": "Liberation Sans",
|
||||||
|
"font_weight": "bold",
|
||||||
|
"margin_bottom": "0.2in"
|
||||||
|
},
|
||||||
|
"Heading 2": {
|
||||||
|
"font_size": "14pt",
|
||||||
|
"font_family": "Liberation Sans",
|
||||||
|
"font_weight": "bold",
|
||||||
|
"margin_bottom": "0.15in"
|
||||||
|
},
|
||||||
|
"Caption": {
|
||||||
|
"font_size": "10pt",
|
||||||
|
"font_family": "Liberation Serif",
|
||||||
|
"font_style": "italic",
|
||||||
|
"text_align": "center"
|
||||||
|
},
|
||||||
|
"Code": {
|
||||||
|
"font_family": "Courier New",
|
||||||
|
"font_size": "10pt",
|
||||||
|
"background_color": "#f0f0f0",
|
||||||
|
"padding": "10px"
|
||||||
|
},
|
||||||
|
"Quote": {
|
||||||
|
"font_style": "italic",
|
||||||
|
"border_left": "3px solid #ccc",
|
||||||
|
"padding_left": "10px"
|
||||||
|
},
|
||||||
|
"Test Style": {
|
||||||
|
"font-family": "Comic Sans MS",
|
||||||
|
"font-size": "20pt",
|
||||||
|
"color": "#ff00ff"
|
||||||
|
},
|
||||||
|
"My New Style": {
|
||||||
|
"font-family": "Georgia",
|
||||||
|
"font-size": "14pt",
|
||||||
|
"font-weight": "",
|
||||||
|
"font-style": "",
|
||||||
|
"text-align": "",
|
||||||
|
"color": "blue",
|
||||||
|
"background-color": "",
|
||||||
|
"padding": "",
|
||||||
|
"border-left": "",
|
||||||
|
"padding-left": "",
|
||||||
|
"margin-bottom": ""
|
||||||
|
}
|
||||||
|
}
|
||||||
+57
-163
@@ -3,173 +3,67 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>GhostEditor - Markdown to Word Converter</title>
|
<title>GhostEditor</title>
|
||||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
|
||||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/5.15.3/css/all.min.css">
|
||||||
<link rel="stylesheet" href="/static/style.css">
|
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="container-fluid">
|
<div class="main-container">
|
||||||
<header class="bg-primary text-white p-3 mb-4">
|
<div class="editor-pane">
|
||||||
<h1 class="mb-0"><i class="fas fa-file-word"></i> GhostEditor</h1>
|
<div class="toolbar">
|
||||||
<p class="mb-0">Real-time Markdown to Word converter with advanced styling and auto-captioning</p>
|
<button id="bold-button" title="Bold"><i class="fas fa-bold"></i></button>
|
||||||
</header>
|
<button id="italic-button" title="Italic"><i class="fas fa-italic"></i></button>
|
||||||
|
<button id="h1-button" title="Heading 1">H1</button>
|
||||||
<div class="row">
|
<button id="h2-button" title="Heading 2">H2</button>
|
||||||
<!-- Settings Panel -->
|
<button id="add-toc" title="Insert TOC"><i class="fas fa-list-ul"></i></button>
|
||||||
<div class="col-md-3">
|
<button id="export-odt" title="Export as ODT"><i class="fas fa-file-export"></i></button>
|
||||||
<div class="card">
|
|
||||||
<div class="card-header">
|
|
||||||
<h5><i class="fas fa-cog"></i> Document Settings</h5>
|
|
||||||
</div>
|
|
||||||
<div class="card-body">
|
|
||||||
<div class="mb-3">
|
|
||||||
<label class="form-label">Document Title</label>
|
|
||||||
<input type="text" id="docTitle" class="form-control" placeholder="Enter document title">
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mb-3">
|
|
||||||
<label class="form-label">Default Text Style</label>
|
|
||||||
<select id="defaultStyle" class="form-select">
|
|
||||||
<option value="Normal">Normal</option>
|
|
||||||
<option value="Heading1">Heading 1</option>
|
|
||||||
<option value="Heading2">Heading 2</option>
|
|
||||||
<option value="Heading3">Heading 3</option>
|
|
||||||
<option value="Quote">Quote</option>
|
|
||||||
<option value="Code">Code</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mb-3">
|
|
||||||
<label class="form-label">Auto Caption Settings</label>
|
|
||||||
<div class="form-check">
|
|
||||||
<input class="form-check-input" type="checkbox" id="autoCaptionImages" checked>
|
|
||||||
<label class="form-check-label" for="autoCaptionImages">Auto-caption images</label>
|
|
||||||
</div>
|
|
||||||
<div class="form-check">
|
|
||||||
<input class="form-check-input" type="checkbox" id="autoCaptionTables" checked>
|
|
||||||
<label class="form-check-label" for="autoCaptionTables">Auto-caption tables</label>
|
|
||||||
</div>
|
|
||||||
<div class="form-check">
|
|
||||||
<input class="form-check-input" type="checkbox" id="autoCaptionFigures" checked>
|
|
||||||
<label class="form-check-label" for="autoCaptionFigures">Auto-caption figures</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mb-3">
|
|
||||||
<label class="form-label">Table Pagination</label>
|
|
||||||
<div class="form-check">
|
|
||||||
<input class="form-check-input" type="checkbox" id="tablePagination" checked>
|
|
||||||
<label class="form-check-label" for="tablePagination">Enable table pagination</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button id="exportBtn" class="btn btn-success w-100">
|
|
||||||
<i class="fas fa-file-export"></i> Export to Word
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card mt-3">
|
|
||||||
<div class="card-header">
|
|
||||||
<h5><i class="fas fa-list"></i> Style Preview</h5>
|
|
||||||
</div>
|
|
||||||
<div class="card-body">
|
|
||||||
<div id="stylePreview">
|
|
||||||
<p class="text-muted">Select text to apply styles</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Editor and Preview -->
|
|
||||||
<div class="col-md-9">
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-md-6">
|
|
||||||
<div class="card">
|
|
||||||
<div class="card-header d-flex justify-content-between align-items-center">
|
|
||||||
<h5><i class="fas fa-edit"></i> Markdown Editor</h5>
|
|
||||||
<div class="btn-group" role="group">
|
|
||||||
<button class="btn btn-outline-secondary btn-sm" id="boldBtn"><b>B</b></button>
|
|
||||||
<button class="btn btn-outline-secondary btn-sm" id="italicBtn"><i>I</i></button>
|
|
||||||
<button class="btn btn-outline-secondary btn-sm" id="headingBtn">H1</button>
|
|
||||||
<button class="btn btn-outline-secondary btn-sm" id="listBtn"><i class="fas fa-list"></i></button>
|
|
||||||
<button class="btn btn-outline-secondary btn-sm" id="imageBtn"><i class="fas fa-image"></i></button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="card-body p-0">
|
|
||||||
<textarea id="markdownInput" class="form-control border-0 rounded-0" rows="20" placeholder="Enter your Markdown here..."></textarea>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="col-md-6">
|
|
||||||
<div class="card">
|
|
||||||
<div class="card-header d-flex justify-content-between align-items-center">
|
|
||||||
<h5><i class="fas fa-eye"></i> Real-time Preview</h5>
|
|
||||||
<div class="form-check form-switch">
|
|
||||||
<input class="form-check-input" type="checkbox" id="previewToggle" checked>
|
|
||||||
<label class="form-check-label" for="previewToggle">Live Preview</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="card-body preview-container">
|
|
||||||
<div id="previewOutput"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Block Styling Panel -->
|
|
||||||
<div class="card mt-3">
|
|
||||||
<div class="card-header">
|
|
||||||
<h5><i class="fas fa-paint-brush"></i> Block Styling</h5>
|
|
||||||
</div>
|
|
||||||
<div class="card-body">
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-md-4">
|
|
||||||
<label class="form-label">Current Block Type</label>
|
|
||||||
<select id="blockType" class="form-select">
|
|
||||||
<option value="paragraph">Paragraph</option>
|
|
||||||
<option value="heading">Heading</option>
|
|
||||||
<option value="list">List</option>
|
|
||||||
<option value="code">Code Block</option>
|
|
||||||
<option value="table">Table</option>
|
|
||||||
<option value="image">Image</option>
|
|
||||||
<option value="quote">Quote</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-4">
|
|
||||||
<label class="form-label">Apply Style</label>
|
|
||||||
<select id="applyStyle" class="form-select">
|
|
||||||
<option value="Normal">Normal</option>
|
|
||||||
<option value="Heading1">Heading 1</option>
|
|
||||||
<option value="Heading2">Heading 2</option>
|
|
||||||
<option value="Heading3">Heading 3</option>
|
|
||||||
<option value="Quote">Quote</option>
|
|
||||||
<option value="Code">Code</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-4">
|
|
||||||
<label class="form-label">Custom Caption</label>
|
|
||||||
<input type="text" id="customCaption" class="form-control" placeholder="Enter custom caption">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="mt-3">
|
|
||||||
<button id="applyStyleBtn" class="btn btn-primary">
|
|
||||||
<i class="fas fa-check"></i> Apply Style
|
|
||||||
</button>
|
|
||||||
<button id="applyCaptionBtn" class="btn btn-info">
|
|
||||||
<i class="fas fa-font"></i> Apply Caption
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
<textarea id="markdown-input" class="markdown-input"></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="preview-pane">
|
||||||
|
<div id="preview-output" class="preview-output"></div>
|
||||||
|
</div>
|
||||||
|
<div class="styles-pane">
|
||||||
|
<h2>Styles</h2>
|
||||||
|
<div id="styles-list"></div>
|
||||||
|
<button id="add-style">Add New Style</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
<div id="style-editor-modal" class="modal">
|
||||||
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
|
<div class="modal-content">
|
||||||
<script src="/static/script.js"></script>
|
<span class="close-button">×</span>
|
||||||
|
<h2 id="style-modal-title">Edit Style</h2>
|
||||||
|
<form id="style-editor-form">
|
||||||
|
<label for="style-name-input">Style Name:</label>
|
||||||
|
<input type="text" id="style-name-input">
|
||||||
|
<label for="font-family-input">Font Family:</label>
|
||||||
|
<input type="text" id="font-family-input">
|
||||||
|
<label for="font-size-input">Font Size:</label>
|
||||||
|
<input type="text" id="font-size-input">
|
||||||
|
<label for="font-weight-input">Font Weight:</label>
|
||||||
|
<input type="text" id="font-weight-input">
|
||||||
|
<label for="font-style-input">Font Style:</label>
|
||||||
|
<input type="text" id="font-style-input">
|
||||||
|
<label for="text-align-input">Text Align:</label>
|
||||||
|
<input type="text" id="text-align-input">
|
||||||
|
<label for="color-input">Color:</label>
|
||||||
|
<input type="text" id="color-input">
|
||||||
|
<label for="background-color-input">Background Color:</label>
|
||||||
|
<input type="text" id="background-color-input">
|
||||||
|
<label for="padding-input">Padding:</label>
|
||||||
|
<input type="text" id="padding-input">
|
||||||
|
<label for="border-left-input">Border Left:</label>
|
||||||
|
<input type="text" id="border-left-input">
|
||||||
|
<label for="padding-left-input">Padding Left:</label>
|
||||||
|
<input type="text" id="padding-left-input">
|
||||||
|
<label for="margin-bottom-input">Margin Bottom:</label>
|
||||||
|
<input type="text" id="margin-bottom-input">
|
||||||
|
<button type="submit">Save Style</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="{{ url_for('static', filename='script.js') }}"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
Reference in New Issue
Block a user